Compare commits

...

61 Commits

Author SHA1 Message Date
Nicholas Tollervey
c1849d28e0 Bump python-minifier to latest version (supports most recent versions of Python). (#2189)
Co-authored-by: Andrea Giammarchi <andrea.giammarchi@gmail.com>
2024-09-27 14:35:59 +01:00
Andrea Giammarchi
ad2af2392b Indirect refs counting (#2188) 2024-09-27 15:26:57 +02:00
Andrea Giammarchi
957ab69c21 Fix #2185 - Updated Polyscript and coincident (#2187) 2024-09-27 12:35:51 +02:00
Nicholas Tollervey
f5d49ee52c Test refinement. (#2183)
* Test refinement.

* [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-09-26 12:02:25 +01:00
Nicholas Tollervey
06138bbb48 Update test suite (#2181)
* pyscript.web tests pass with upytest.
* Refactor of old integration tests to new Python tests.
* Added comprehensive test suite for Python based `pyscript` module.
* Add integration tests to Makefile (and CI)
* Remove un-needed upload action.
* Ensure fails are properly logged as an array. Remove the explicit test step, since this is already built into the build step.
* Bump polyscript.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Andrea Giammarchi <andrea.giammarchi@gmail.com>
2024-09-25 16:23:59 +01:00
Andrea Giammarchi
ae66d13d57 Fixed config issue via polyscript update (#2182) 2024-09-24 11:01:20 +02:00
Andrea Giammarchi
5aaeebf32c Make WebSocket lazy attributes definition possible (#2180) 2024-09-23 13:19:26 +02:00
Andrea Giammarchi
a6b0964185 Fix #2155 - Allow editor.process to run on behalf of users (#2177)
* Fix #2155 - Allow editor.process to run on behalf of users

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

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

* Updated dev/dependendencies

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-09-20 15:42:29 +02:00
Andrea Giammarchi
dd86169f2a Exposing config type once resolved and running (#2175) 2024-09-17 17:04:32 +02:00
Andrea Giammarchi
84c7d69db9 Remove useless test_report (#2169) 2024-09-15 14:29:55 +02:00
nasrin pathan
ca9b565adc Update README.md (#2170) 2024-09-15 14:29:03 +02:00
Andrea Giammarchi
b2d1018db1 Fix #2167 - Provide instructions to build the project (#2168)
* Fix #2167 - Provide instructions to build the project

* [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-09-13 16:58:32 +02:00
Nicholas Tollervey
c4e25d879e Update GH actions to node 20 and Python env for PyMinifier. (#2166)
* Update GH actions to node 20 and Python env for PyMinifier.

* Fix spaces.

* Fix test.yml

* [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-09-13 14:37:17 +01:00
Andrea Giammarchi
c82dbb755e cleanup npm package (#2163) 2024-09-13 15:04:21 +02:00
Andrea Giammarchi
1ed77321a5 Add a storage equivalent for JS (#2165)
* Add a storage equivalent for JS

* [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-09-13 12:05:40 +02:00
Andrea Giammarchi
e36a57eb06 Fix #2160 - Implement progress events (#2162) 2024-09-12 18:41:36 +02:00
Andrea Giammarchi
ee3cd76022 Follow up - Remove all innerHTML += for consistency sake (#2159) 2024-09-11 15:12:04 +02:00
Andrea Giammarchi
eb31e51a45 Fix #2150 - Avoid trashing previous added elements (#2151) 2024-09-11 11:37:42 +02:00
Andrea Giammarchi
c8c2dd0806 Avoid throwing if Pyodide does not await due missing arguments (#2158)
* Fix #2156 - Avoid requiring args for async functions

* [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-09-06 15:16:28 +02:00
Andrea Giammarchi
e525d54be0 Fix #2156 - Test @when with async listener (#2157) 2024-09-06 14:56:50 +02:00
Andrea Giammarchi
7b9f7c13f5 Improve by far error reporting around PyEditor bootstrap (#2153)
* Improve by far error reporting around PyEditor bootstrap

* Improve by far error reporting around PyEditor bootstrapiImprove by far error reporting around PyEditor bootstrap

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-09-04 16:52:08 +02:00
Andrea Giammarchi
7582cbef9c Fix #2146 - Workaround Pyodide issue with attributes (#2148)
* Fix #2146 - Workaround Pyodide issue with attributes

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

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

* Fix #2146 - Updated polyscript

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-08-27 16:47:52 +02:00
Andrea Giammarchi
b395cde49c Forgot to build tests/index.html and push (#2144) 2024-08-26 12:29:16 +02:00
Andrea Giammarchi
9f46234f71 Fix #2114 - Cleanup the test folder + automation (#2143)
* Fix #2114 - Cleanup the test folder + automation
* Merged both test and tests into a single folder
2024-08-08 17:08:59 +02:00
Andrea Giammarchi
f4c4edeb29 Implemented pyminify for our stdlib (#2140)
* Implemented pyminify for our stdlib

* [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>
2024-08-07 12:33:20 +02:00
Andrea Giammarchi
7166c32384 Fix #2093 - Show setup errors with the editor (#2138)
* Fix #2093 - Show setup errors with the editor

* [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-08-06 16:31:42 +02:00
pre-commit-ci[bot]
ed126889ae [pre-commit.ci] pre-commit autoupdate (#2137)
updates:
- [github.com/psf/black: 24.4.2 → 24.8.0](https://github.com/psf/black/compare/24.4.2...24.8.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-08-06 12:36:00 +02:00
Andrea Giammarchi
0d0ea96435 Defaulting to async for top-level await (#2134) 2024-08-05 15:55:53 +02:00
Andrea Giammarchi
fafdf74007 Fixed async methods attached to window (#2136) 2024-08-05 11:58:22 +02:00
Martin
999897df12 The all-new, pyscript.web (ignore the branch name :) ) (#2129)
* Minor cleanups: move all Element classes to bottom of module.

* Commenting.

* Commenting.

* Commenting.

* Group dunder methods.

* Don't cache the element's parent.

* Remove style type check until we decide whether or not to add for classes too.

* Add ability to register/unregister element classes.

* Implement __iter__ for container elements.

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

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

* Minor renaming to make it clear when we have an Element instance vs an actual DOM element.

* remove duplication: added Element.get_tag_name

* Commenting.

* Allow Element.append to 1) use *args, 2) accept iterables

* Remove iterable check - inteferes with js proxies.

* Don't use *args, so it quacks more like a list ;)

* Element.append take 2 :)

* Remove unused code.

* Move to web.py with a page object!

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

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

* Added 'page.title' too :)

* Add __getitem__ as a shortcut for page.find

* Add Element.__getitem__ to be consistent

* Make __getitem__ consistent for Page, Element and ElementCollection.

* Docstringing.

* Docstringing.

* Docstringing/commenting.

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

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

* fix select.add (revert InnerHTML->html)

* Commenting.

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

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

* Hand-edit some of the AI :)

* Rename ElementCollection.children -> ElementCollection.elements

* Remove unnecessary guard.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-08-01 10:36:57 +01:00
Andrea Giammarchi
d47fb58ede Update Pyodide to its 0.26.2 version (#2133) 2024-07-31 20:34:21 +02:00
Andrea Giammarchi
f316341e73 Updated Polyscript and added Panel worker test (#2130)
* Updated Polyscript and added Panel worker test

* [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-07-31 14:31:35 +02:00
Andrea Giammarchi
8c46fcabf7 Updating polyscript to its latest (#2128)
* Updating polyscript to its latest
2024-07-29 16:59:31 +02:00
Martin
e4ff4d8fab Controversial version where Element just delegates to the underlying DOM element. (#2127)
* Update elements.py

* remove grid which allows simpler class tag mapping.
2024-07-24 13:27:12 -05:00
Martin
f20a0003ed fix: broken methods video.snap and canvas.download (#2126)
* fix: broken methods video.snap and canvas.download

* Allow canvas.draw to use actual image width/height.
2024-07-23 15:35:07 -05:00
Martin
6c938dfe3b Override __getattr__ and __setattr__ on ElementCollection. (#2116)
* Override __getattr__ and __setattr__ on ElementCollection.

* fix: bug when using a string to query an ElementCollection.

* Use Element.find when indexing ElementCollection with a string.

* For consistency also have a find method on ElementCollection.

* ElementCollection.find now returns a collection of collections :)

* fix tests: for textContent

* Revert to extend for ElementCollection.find :)

* Make element_from_dom a classmethod Element.from_dom

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

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

* rename: Element.from_dom -> Element.from_dom_element

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

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

* PyCharm warning sweep.

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

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

* Workaround for mp not allowing setting via __dict__

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-07-22 10:32:37 -05:00
Andrea Giammarchi
d884586a82 Updated Polyscript to its latest (#2124) 2024-07-19 12:21:04 +02:00
Fabio Pliger
f8f7ba89c1 Cleanup pyscript web elements (#2094)
* change pydom example to use new pyscript.web namespace

* change tests to use new pyscript.web namespace

* create new pyscript.web package and move pydom to pyscript.web.dom

* add __init__ to pyscript.web and expose the dom instance instead of the pyscript.web.dom module

* move elements from pyweb.ui to pyscript.web and temp fix pydom import

* moved of elements file completed

* moved media from pyweb to pyscript.web

* RIP pyweb

* move JSProperty from pyscript.web.dom to pyscript.web.elements

* move element classes from pyscript.web.dom to pyscript.web.elements

* first round of fixes while running tests

* fix test typo

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

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

* restore right type type returned for Element.parent. ALL TESTS PASS LOCALLY NOW

* lint

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

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

* clean up dom.py from dead commented code and osbolete comments

* bugfix: dom shouldn't return None when it can't find any element for a specific selector so it now returns an empty collection

* additional cleanup in tests

* lint

* initial cleaning up of unused modules

* change element.append to not consider types anymore and add tests for appending elements.Element or a JsProxy object

* add Element.append tests for append JS elements directly and appending nodeList as well

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

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

* Tag and create the correct subclass of Element.

* Move: Element.snap -> video.snap

* Move: Element.download and draw to canvas.download and draw.

* Minor cleanups.

* Commenting.

* Allow css classes to be passed to Element constructor.

* Commenting.

* Typo fix.

* Make html, id and text JSProperties.

* Commenting.

* Remove unnecessary selected attribute on BaseElement.

* Extract: BaseElement.from_js -> element_from_js

* Pass *args and **kwargs to element_from_js and remove BaseElement.create

* Move value attribute to specific Element subclasses.

* fix: wrapping of existing js elements.

* Add body and head elements so parent and children work everywhere.

* Revert order of HasOptions mixin for the select element.

* Comment out tests that are no longer relevant (see comment).

* Use correct super args in mixin.

* Have to use element_from_js when returning options from OptionsProxy.

* rename: StyleProxy -> Style, OptionsProxy -> Options and added Classes.

* Remove cached_property.

* Remove list-y methods from Classes collection.

* Allow explicit children or *args for containers.

* controversial: fix tests to use find rather than dom

* Add html element so (say) body.parent does what is expected.

* Collapse Element class hierarchy.

* rename: js_element -> dom_element

* rename: element_from_js -> element_from_dom

* replace: JS with DOM

* rename: _js -> _dom_element

* fix dom tests.

* Complete element list with void elements derived from Element.

* Added attributes to the newly added Element subclasses.

* remove dom module, replace with instance.

Also, remove media :)

* fix: typo in test for 'b' element.

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

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

* Remove dom and media modules.

* fix up ts definitions.

* Added missing import (used in content property).

* Added TODO :)

* wip: Add ClassesCollection

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

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

* Attempt to ask black to leave class list alone.

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

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

* Add classes attribute to ElementCollection

* wip: work on classes collection

* Extract code to get set of all class names in ClassesCollection.

* Update elements.py

* Polishing.

* Make children return an ElementCollection

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

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

* wip: Add the ability to set multiple properties.

* Add __getitem__ back to the dom object.

* Put validation when setting DOM properties back in.

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

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

* All tests green.

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

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

* Remove unnecessary comment.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Martin <martin.chilvers@gmail.com>
2024-07-03 14:21:23 -07:00
Andrea Giammarchi
67d47511d5 Fix MicroPython terminal input when no REPL is used/needed (#2113)
* Fix terminal input when no REPL is used/needed
* Fix input backspace too
2024-07-03 13:03:31 +02:00
Andrea Giammarchi
6f49f18937 Updated Polyscript with its workers feature (#2104)
* Updated Polyscript with its workers feature
* Worked around the inconsistent behavior between Pyodide and MicroPython
* Fixed Pyodide greedy access to undesired Proxy fields
2024-06-26 14:01:22 +02:00
Andrea Giammarchi
7b8ef7ebe2 Fix #2109 - Allow inline JSON config attribute in PyEditor (#2110)
Fix #2109 - Allow inline JSON config attribute in PyEditor
2024-06-24 17:04:28 +02:00
Andrea Giammarchi
461ae38763 Updated reference code to grab latest (#2107) 2024-06-21 16:06:18 +02:00
Andrea Giammarchi
4b90ebdef5 Bring back pyweb as it was (#2105) 2024-06-21 14:49:20 +02:00
Andrea Giammarchi
15c19aa708 Updated Polyscript with latest MicroPython (#2103) 2024-06-19 17:56:22 +02:00
Andrea Giammarchi
d0406be84c A persistent IndexedDB store for PyScript (#2101)
A persistent IndexedDB store for PyScript
2024-06-19 14:11:57 +02:00
Andrea Giammarchi
aab015b9b8 Better py editor indentation (#2098)
Better PyEditor Indentation
2024-06-13 11:34:14 +02:00
Andrea Giammarchi
a1e5a05b49 PyEditor cumulative fixes & improvements (#2095)
* PyEditor fixes

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

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

* PyEditor cumulative fixes & improvements

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-06-12 18:55:36 +02:00
Fabio Pliger
f1a787e031 move pydom and elements from pyweb to pyscript.web (#2092)
* change pydom example to use new pyscript.web namespace

* change tests to use new pyscript.web namespace

* create new pyscript.web package and move pydom to pyscript.web.dom

* add __init__ to pyscript.web and expose the dom instance instead of the pyscript.web.dom module

* move elements from pyweb.ui to pyscript.web and temp fix pydom import

* moved of elements file completed

* moved media from pyweb to pyscript.web

* RIP pyweb

* move JSProperty from pyscript.web.dom to pyscript.web.elements

* move element classes from pyscript.web.dom to pyscript.web.elements

* first round of fixes while running tests

* fix test typo

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

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

* restore right type type returned for Element.parent. ALL TESTS PASS LOCALLY NOW

* lint

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

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

* clean up dom.py from dead commented code and osbolete comments

* bugfix: dom shouldn't return None when it can't find any element for a specific selector so it now returns an empty collection

* additional cleanup in tests

* lint

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-06-06 15:42:14 +02:00
Fabio Pliger
b41cfb7b60 UI creation API (#1960)
* add JSProperty to pydom so it can be used to create descriptors mapping to specific JS attributes

* add pyweb.ui

* fix pyweb imports

* fix link and a elements and add a script element

* fix imports and add initialization to load resources to shoelace module

* new pyweb.ui test folder

* remove comments

* add Icon to shoelace components

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

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

* use html property rather than accessing _js directly

* add markdown suppport

* move examples section out of stdlib pyweb to examples.py in the demo itself

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

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

* simplify demo code

* improve docstrings

* precommit fixes

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

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

* simplify code for main page

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

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

* add load_resources to markdown

* add showlace extra style dynamically

* precommit

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

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

* add gallery files

* add global attributes and dynamic property assignment

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

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

* Add shoelace radio component (#1961)

* add shoelace radio component

* [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>

* fix type that lead using the the JSDescriptor directly instead of the factory method

* add missing marked dependency

* refactor gallary to simplify codebase

* precommig lint

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

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

* add text attribute to pydom Elements

* add global JS attributes to elements and improve demos

* lint

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

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

* fix image instantiation on card since the API has changed

* add attributes to all classes in elements

* lint

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

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

* change example creation functions to take the label and the object directly

* fix input name clashing with input keyword

* refactor examples to better simplify and automate

* fix div clashing names

* fix demo left menu width

* simplify base elements demo by moving all the examples to the examples module

* rename Grid to grid to align to other elements

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

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

* reorg classes order in elements.py and add missing elements to examples

* fix issue related to now importing div from pyweb.ui

* improve demo

* link and fix spelling typo

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

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

* Add a bunch more elements (#1966)

* Add copy button

* Add skeleton and spinner

* Add Switch

* Add text area

* Add more elements

* More styling to each component

* [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>
Co-authored-by: Fabio Pliger <fpliger@users.noreply.github.com>

* Add radio group (#1963)

* add radio group

* [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>
Co-authored-by: Fabio Pliger <fpliger@users.noreply.github.com>

* Small tweaks to main demo page (#1962)

* Small tweaks to main demo page

* [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>
Co-authored-by: Fabio Pliger <fpliger@users.noreply.github.com>

* fix post merge issues

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

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

* fixed issues with renaming Grid to grid, after we merged

* lint

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

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

* add multple HTML elements in alphabetical order from abbr to em

* fix attributes of some of the elements added in the previous commit and add embed

* fix embed attributes and add fieldset

* add figcation, figure and form. Also fix ordering of definitoin of img and input_

* add style and lint

* wrap up adding all major html elements

* fix test failing due to different error message from fake server compared to a real test server

* change default docstring associated with all classes dynamically patched

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

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

* lint

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

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

* add pyweb tests

* lint

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

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

* add global attributes and change abbr test

* fix abbr to inherit from TextElementBase

* add address test and improve error messaging when ElementBase gets a bad input as style

* change test helper function to be more  flexible on attributes and manage content vs non content based elements. Also adds area tests

* add test for more elements up to caption

* fix canvas and caption as elements that have content and fix name typo on figcaption

* fix another typo on figcaption

* minor fixes and complete tests for all elements

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

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

* adapt shoelace to latest upates in ui.elements

* fix issue with example code not showing created button

* move global attributes to base class

* replace all the calls to _add_js_properties with defining attributes directly in the classes

* finish moving all properties manually on each class

* remove _add_js_properties

* replace JSProperty with js_property

* replace JSProperty with js_property

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

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

* fixed merge conflicts

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

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

* remove js_property and just use JSProperty with name and allow_nones as arguments

* fix bug around Element not being able to map global attributes in subclasses

* remove js_property and fix references

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

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

* precommit

* precommit again

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

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

* lint

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

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

* enable pyweb on micropython

* switch examples to micropython

* fix pydom issue with micropython, created by the monkeypatch around JsProxy

* print micropython version on micropython pydom example

* lint and remove of textwrap in stdlib for micropython compatibility

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

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

* added msising attributes on the option Element. Tests are all passing now

* fix tests

* lint

* temp ugly fix for micropython, using the when decorator with a function without arguments

* small fixes and improvements to examples

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

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

* fix examples and broken link from the removal of markdown and shoelace from stdlib

* lint

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

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

* dynamically change the server address in tests

* use the right element in test_a

* lint

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

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

* skipping test_audio in CI due to different behavior of fake_server vs a real server, that runs in local tests

* add conditional expected_missing_file_errors property to manage different behaviour between local tests and CI (due to fake_server)

* lint

* [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>
Co-authored-by: Askat <aaskat@users.noreply.github.com>
Co-authored-by: Fábio Rosado <hello@fabiorosado.dev>
Co-authored-by: Andrea Giammarchi <andrea.giammarchi@gmail.com>
2024-06-04 13:30:34 -07:00
Andrea Giammarchi
1c675307e1 Expose pyscript.py_import and js_import for lazy Python/JS modules (#2091)
* Expose pyscript.py_modules for lazy Python modules

* [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>
2024-06-04 19:08:52 +02:00
pre-commit-ci[bot]
ac56f82c6d [pre-commit.ci] pre-commit autoupdate (#2089)
updates:
- [github.com/codespell-project/codespell: v2.2.6 → v2.3.0](https://github.com/codespell-project/codespell/compare/v2.2.6...v2.3.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-06-04 09:36:04 +02:00
Andrea Giammarchi
2ac5ca79d7 Expose py-editor code content read/write (#2087)
* Expose py-editor code content read/write

* [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-06-03 12:32:54 +02:00
Jeff Glass
cb9ee6f7e2 Update README.md, 2023.11.2 -> 2024.5.2 (#2085) 2024-06-03 10:14:27 +02:00
Andrea Giammarchi
9abaef33bd Fix #2067 - Enable .whl packages (#2084) 2024-05-31 11:50:30 +02:00
Brendan
320a537db2 Add step in build process to publish a tarfile (#2077)
Co-authored-by: Andrea Giammarchi <andrea.giammarchi@gmail.com>
2024-05-30 16:25:58 +02:00
Andrea Giammarchi
9b775ce015 Enhance MicroPython Terminal on both Main and Worker (#2083)
* Allow MicroPython Terminal on Main

* [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

* [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-05-30 13:36:42 +02:00
jdw170000
66f72eda1e Add spinner to py-editor run buttons (#2078)
* Add spinner to disabled py-editor run buttons

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

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

* css nit suggested by WebReflection

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-05-29 17:36:06 +02:00
Martin
39ca29749c fix: typo in "isinstance" in Element.snap. (#2071)
Co-authored-by: Fabio Pliger <fpliger@users.noreply.github.com>
Co-authored-by: Andrea Giammarchi <andrea.giammarchi@gmail.com>
2024-05-27 12:08:49 +02:00
Amjith Ramanujam
85da548447 Fix the links to development process. (#2064)
Co-authored-by: Andrea Giammarchi <andrea.giammarchi@gmail.com>
2024-05-27 11:47:25 +02:00
jdw170000
9985787e4b Update dependencies and deprecated linter configuration format (#2076)
* update python dependencies to latest versions

* isort automatic formatting nits

* update eslint config to non-deprecated flat format

* `npm update` to upgrade javascript dependencies

* [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-05-27 11:44:24 +02:00
Takanori Suzuki
18ec6ce775 refs #2068 fix links on CONTRIBUTING.md (#2072) 2024-05-21 10:36:41 +01:00
201 changed files with 6573 additions and 8232 deletions

View File

@@ -19,7 +19,22 @@ jobs:
- name: Install node
uses: actions/setup-node@v4
with:
node-version: 18.x
node-version: 20.x
- name: Python venv
run: python -m venv env
- name: Activate Python
run: source env/bin/activate
- name: Update pip
run: pip install --upgrade pip
- name: Install PyMinifier
run: pip install --ignore-requires-python python-minifier
- name: Install Setuptools
run: pip install setuptools
- name: Cache node modules
uses: actions/cache@v4
@@ -35,7 +50,7 @@ jobs:
${{ runner.os }}-
- name: NPM Install
run: npm install && npx playwright install
run: npm install && npx playwright install chromium
- name: Build
run: npm run build

View File

@@ -21,7 +21,22 @@ jobs:
- name: Install node
uses: actions/setup-node@v4
with:
node-version: 18.x
node-version: 20.x
- name: Python venv
run: python -m venv env
- name: Activate Python
run: source env/bin/activate
- name: Update pip
run: pip install --upgrade pip
- name: Install PyMinifier
run: pip install --ignore-requires-python python-minifier
- name: Install Setuptools
run: pip install setuptools
- name: Cache node modules
uses: actions/cache@v4
@@ -37,7 +52,7 @@ jobs:
${{ runner.os }}-
- name: npm install
run: npm install && npx playwright install
run: npm install && npx playwright install chromium
- name: build
run: npm run build
@@ -46,6 +61,10 @@ jobs:
working-directory: .
run: sed 's#_PATH_#https://pyscript.net/releases/${{ github.ref_name }}/#' ./public/index.html > ./pyscript.core/dist/index.html
- name: Generate release.tar from snapshot and put it in dist/
working-directory: .
run: tar -cvf ../release.tar * && mv ../release.tar .
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:

View File

@@ -25,7 +25,22 @@ jobs:
- name: Install node
uses: actions/setup-node@v4
with:
node-version: 18.x
node-version: 20.x
- name: Python venv
run: python -m venv env
- name: Activate Python
run: source env/bin/activate
- name: Update pip
run: pip install --upgrade pip
- name: Install PyMinifier
run: pip install --ignore-requires-python python-minifier
- name: Install Setuptools
run: pip install setuptools
- name: Cache node modules
uses: actions/cache@v4
@@ -41,7 +56,7 @@ jobs:
${{ runner.os }}-
- name: Install Dependencies
run: npm install && npx playwright install
run: npm install && npx playwright install chromium
- name: Build Pyscript.core
run: npm run build

View File

@@ -26,7 +26,22 @@ jobs:
- name: Install node
uses: actions/setup-node@v4
with:
node-version: 18.x
node-version: 20.x
- name: Python venv
run: python -m venv env
- name: Activate Python
run: source env/bin/activate
- name: Update pip
run: pip install --upgrade pip
- name: Install PyMinifier
run: pip install --ignore-requires-python python-minifier
- name: Install Setuptools
run: pip install setuptools
- name: Cache node modules
uses: actions/cache@v4
@@ -42,7 +57,7 @@ jobs:
${{ runner.os }}-
- name: NPM Install
run: npm install && npx playwright install
run: npm install && npx playwright install chromium
- name: Build
run: npm run build

View File

@@ -69,12 +69,7 @@ jobs:
make setup
- name: Build
run: make build
- name: Integration Tests
#run: make test-integration-parallel
run: |
make test-integration
run: make build # Integration tests run in the build step.
- uses: actions/upload-artifact@v4
with:
@@ -83,10 +78,3 @@ jobs:
pyscript.core/dist/
if-no-files-found: error
retention-days: 7
- uses: actions/upload-artifact@v4
if: success() || failure()
with:
name: test_results
path: test_results/
if-no-files-found: error

View File

@@ -1,16 +0,0 @@
name: Test Report
on:
workflow_run:
workflows: ['\[CI\] Test']
types:
- completed
jobs:
report:
runs-on: ubuntu-latest-8core
steps:
- uses: dorny/test-reporter@v1.9.0
with:
artifact: test_results
name: Test reports
path: "*.xml"
reporter: java-junit

1
.gitignore vendored
View File

@@ -142,6 +142,7 @@ coverage/
test_results
# @pyscript/core npm artifacts
pyscript.core/test-results/*
pyscript.core/core.*
pyscript.core/dist
pyscript.core/dist.zip

View File

@@ -25,13 +25,13 @@ repos:
- id: trailing-whitespace
- repo: https://github.com/psf/black
rev: 24.4.2
rev: 24.8.0
hooks:
- id: black
exclude: pyscript\.core/src/stdlib/pyscript/__init__\.py
- repo: https://github.com/codespell-project/codespell
rev: v2.2.6
rev: v2.3.0
hooks:
- id: codespell # See 'pyproject.toml' for args
exclude: \.js\.map$

View File

@@ -1,5 +1,15 @@
# Release Notes
## 2024.05.21
### Features
### Bug fixes
### Enhancements
- `py-editor` run buttons now display a spinner when disabled, which occurs when the editor is running code.
## 2023.05.01
### Features

View File

@@ -59,9 +59,9 @@ If you would like to contribute to PyScript, but you aren't sure where to begin,
## Setting up your local environment and developing
If you would like to contribute to PyScript, you will need to set up a local development environment. The [following instructions](https://pyscript.github.io/docs/latest/development/setting-up-environment.html) will help you get started.
If you would like to contribute to PyScript, you will need to set up a local development environment. The [following instructions](https://docs.pyscript.net/latest/contributing/#set-up-your-development-environment) will help you get started.
You can also read about PyScript's [development process](https://pyscript.github.io/docs/latest/development/developing.html) to learn how to contribute code to PyScript, how to run tests and what's the PR etiquette of the community!
You can also read about PyScript's [development process](https://docs.pyscript.net/latest/developers/) to learn how to contribute code to PyScript, how to run tests and what's the PR etiquette of the community!
## License terms for contributions

View File

@@ -12,7 +12,7 @@ all:
@echo "make clean - clean up auto-generated assets."
@echo "make build - build PyScript."
@echo "make precommit-check - run the precommit checks (run eslint)."
@echo "make test-integration - run all integration tests sequentially."
@echo "make test - run all automated tests in playwright."
@echo "make fmt - format the code."
@echo "make fmt-check - check the code formatting.\n"
@@ -45,7 +45,6 @@ ifeq ($(VIRTUAL_ENV),)
false
else
python -m pip install -r requirements.txt
playwright install
endif
# Clean up generated assets.
@@ -56,21 +55,15 @@ clean:
# Build PyScript.
build:
cd pyscript.core && npx playwright install && npm run build
cd pyscript.core && npx playwright install chromium && npm run build
# Run the precommit checks (run eslint).
precommit-check:
pre-commit run --all-files
# Run all integration tests sequentially.
test-integration:
mkdir -p test_results
pytest -vv $(ARGS) pyscript.core/tests/integration/ --log-cli-level=warning --junitxml=test_results/integration.xml
# Run all integration tests in parallel.
test-integration-parallel:
mkdir -p test_results
pytest --numprocesses auto -vv $(ARGS) pyscript.core/tests/integration/ --log-cli-level=warning --junitxml=test_results/integration.xml
# Run all automated tests in playwright.
test:
cd pyscript.core && npm run test:integration
# Format the code.
fmt: fmt-py

View File

@@ -38,11 +38,11 @@ To try PyScript, import the appropriate pyscript files into the `<head>` tag of
<head>
<link
rel="stylesheet"
href="https://pyscript.net/releases/2023.11.2/core.css"
href="https://pyscript.net/releases/2024.8.2/core.css"
/>
<script
type="module"
src="https://pyscript.net/releases/2023.11.2/core.js"
src="https://pyscript.net/releases/2024.8.2/core.js"
></script>
</head>
<body>
@@ -67,9 +67,11 @@ Check out the [official docs](https://docs.pyscript.net/) for more detailed docu
## How to Contribute
Read the [contributing guide](CONTRIBUTING.md) to learn about our development process, reporting bugs and improvements, creating issues and asking questions.
Read the [contributing guide](https://docs.pyscript.net/latest/contributing/) to learn about our development process, reporting bugs and improvements, creating issues and asking questions.
Check out the [developing process](https://pyscript.github.io/docs/latest/contributing) documentation for more information on how to setup your development environment.
Check out the [developing process](https://docs.pyscript.net/latest/developers/) documentation for more information on how to setup your development environment.
For technical details of the code, please see the [README](pyscript.core/README) in `pyscript.core`.
## Governance

View File

@@ -1,26 +0,0 @@
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: "eslint:recommended",
overrides: [
{
env: {
node: true,
},
files: [".eslintrc.{js,cjs}"],
parserOptions: {
sourceType: "script",
},
},
],
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
ignorePatterns: ["3rd-party"],
rules: {
"no-implicit-globals": ["error"],
},
};

View File

@@ -1,11 +0,0 @@
.eslintrc.cjs
.pytest_cache/
node_modules/
rollup/
test/
tests/
test-results/
src/stdlib/_pyscript
src/stdlib/pyscript.py
package-lock.json
tsconfig.json

View File

@@ -12,7 +12,7 @@ Clone this repository then run `npm install` within its folder.
Use `npm run build` to create all artifacts and _dist_ files.
Use `npm run server` to test locally, via the `http://localhost:8080/test/` url, smoke tests or to test manually anything you'd like to check.
Use `npm run server` to test locally, via the `http://localhost:8080/tests/` url, smoke tests or to test manually anything you'd like to check.
### Artifacts
@@ -37,13 +37,25 @@ make setup
This will create a tests environment [in the root of the project, named `./env`]and install all the dependencies needed to run the tests.
After the command has completed and the tests environment has been created, you can run the **integration tests** with
After the command has completed and the tests environment has been created, you can run the **automated tests** with
the following command:
```
make test-integration
make test
```
(This essentially runs the `npm run test:integration` command in the right place. This is defined in PyScript's `package.json` file.)
Tests are found in the `tests` directory. These are organised into three locations:
1. `python` - the Python based test suite to exercise Python code **within** PyScript.
2. `javascript` - JavaScript tests to exercise PyScript itself, in the browser.
3. `manual` - containing tests to run manually in a browser, due to the complex nature of the tests.
We use [Playwright](https://playwright.dev/) to automate the running of the Python and JavaScript test suites. We use [uPyTest](https://github.com/ntoll/upytest) as a test framework for the Python test suite. uPyTest is a "PyTest inspired" framework for running tests in the browser on both MicroPython and Pyodide.
The automated (Playwright) tests are specified in the `tests/integration.spec.js` file.
## `pyscript` python package
The `pyscript` package available in _Python_ lives in the folder `src/stdlib/pyscript/`.

View File

@@ -0,0 +1,22 @@
import globals from "globals";
import js from "@eslint/js";
export default [
js.configs.recommended,
{
ignores: ["**/3rd-party/"],
},
{
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser,
...globals.es2021,
},
},
rules: {
"no-implicit-globals": ["error"],
},
},
];

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@pyscript/core",
"version": "0.4.34",
"version": "0.5.15",
"type": "module",
"description": "PyScript",
"module": "./index.js",
@@ -8,6 +8,15 @@
"jsdelivr": "./jsdelivr.js",
"browser": "./index.js",
"main": "./index.js",
"files": [
"./dist/",
"./src/",
"./types/",
"./index.js",
"./jsdelivr.js",
"LICENSE",
"README.md"
],
"exports": {
".": {
"types": "./types/core.d.ts",
@@ -16,18 +25,23 @@
"./css": {
"import": "./dist/core.css"
},
"./storage": {
"import": "./dist/storage.js"
},
"./package.json": "./package.json"
},
"scripts": {
"server": "npx static-handler --coi .",
"build": "export ESLINT_USE_FLAT_CONFIG=false; npm run build:3rd-party && npm run build:stdlib && npm run build:plugins && npm run build:core && eslint src/ && npm run ts && npm run test:mpy",
"server": "echo \"➡️ TESTS @ $(tput bold)http://localhost:8080/tests/$(tput sgr0)\"; npx static-handler --coi .",
"build": "export ESLINT_USE_FLAT_CONFIG=true;npm run build:3rd-party && npm run build:stdlib && npm run build:plugins && npm run build:core && npm run build:tests-index && if [ -z \"$NO_MIN\" ]; then eslint src/ && npm run ts && npm run test:integration; fi",
"build:core": "rm -rf dist && rollup --config rollup/core.config.js && cp src/3rd-party/*.css dist/",
"build:flatted": "node rollup/flatted.cjs",
"build:plugins": "node rollup/plugins.cjs",
"build:stdlib": "node rollup/stdlib.cjs",
"build:3rd-party": "node rollup/3rd-party.cjs",
"build:tests-index": "node rollup/build_test_index.cjs",
"clean:3rd-party": "rm src/3rd-party/*.js && rm src/3rd-party/*.css",
"test:mpy": "static-handler --coi . 2>/dev/null & SH_PID=$!; EXIT_CODE=0; playwright test --fully-parallel test/mpy.spec.js || EXIT_CODE=$?; kill $SH_PID 2>/dev/null; exit $EXIT_CODE",
"test:ws": "bun test/ws/index.js & playwright test test/ws.spec.js",
"test:integration": "static-handler --coi . 2>/dev/null & SH_PID=$!; EXIT_CODE=0; playwright test --fully-parallel tests/js_tests.spec.js tests/py_tests.spec.js || EXIT_CODE=$?; kill $SH_PID 2>/dev/null; exit $EXIT_CODE",
"test:ws": "bun tests/ws/index.js & playwright test tests/ws.spec.js",
"dev": "node dev.cjs",
"release": "npm run build && npm run zip",
"size": "echo -e \"\\033[1mdist/*.js file size\\033[0m\"; for js in $(ls dist/*.js); do cat $js | brotli > ._; echo -e \"\\033[2m$js:\\033[0m $(du -h --apparent-size ._ | sed -e 's/[[:space:]]*._//')\"; rm ._; done",
@@ -42,34 +56,39 @@
"license": "APACHE-2.0",
"dependencies": {
"@ungap/with-resolvers": "^0.1.0",
"@webreflection/idb-map": "^0.3.1",
"basic-devtools": "^0.1.6",
"polyscript": "^0.12.10",
"polyscript": "^0.15.11",
"sabayon": "^0.5.2",
"sticky-module": "^0.1.1",
"to-json-callback": "^0.1.1",
"type-checked-collections": "^0.1.7"
},
"devDependencies": {
"@codemirror/commands": "^6.5.0",
"@codemirror/commands": "^6.6.2",
"@codemirror/lang-python": "^6.1.6",
"@codemirror/language": "^6.10.1",
"@codemirror/language": "^6.10.3",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.26.3",
"@playwright/test": "^1.44.0",
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-node-resolve": "^15.2.3",
"@codemirror/view": "^6.34.1",
"@playwright/test": "1.45.3",
"@rollup/plugin-commonjs": "^28.0.0",
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-terser": "^0.4.4",
"@webreflection/toml-j0.4": "^1.1.3",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"bun": "^1.1.8",
"chokidar": "^3.6.0",
"bun": "^1.1.29",
"chokidar": "^4.0.1",
"codedent": "^0.1.2",
"codemirror": "^6.0.1",
"eslint": "^9.2.0",
"rollup": "^4.17.2",
"eslint": "^9.11.1",
"flatted": "^3.3.1",
"rollup": "^4.22.5",
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-string": "^3.0.0",
"static-handler": "^0.4.3",
"typescript": "^5.4.5",
"static-handler": "^0.5.3",
"string-width": "^7.2.0",
"typescript": "^5.6.2",
"xterm": "^5.3.0",
"xterm-readline": "^1.1.1"
},

View File

@@ -0,0 +1,73 @@
const { join } = require("node:path");
const { lstatSync, readdirSync, writeFileSync } = require("node:fs");
// folders to not consider while crawling
const EXCLUDE_DIR = new Set(["ws"]);
const TEST_DIR = join(__dirname, "..", "tests");
const TEST_INDEX = join(TEST_DIR, "index.html");
const crawl = (path, tree = {}) => {
for (const file of readdirSync(path)) {
const current = join(path, file);
if (current === TEST_INDEX) continue;
if (lstatSync(current).isDirectory()) {
if (EXCLUDE_DIR.has(file)) continue;
const sub = {};
tree[file] = sub;
crawl(current, sub);
if (!Reflect.ownKeys(sub).length) {
delete tree[file];
}
} else if (file.endsWith(".html")) {
const name = file === "index.html" ? "." : file.slice(0, -5);
tree[name] = current.replace(TEST_DIR, "");
}
}
return tree;
};
const createList = (tree) => {
const ul = ["<ul>"];
for (const [key, value] of Object.entries(tree)) {
ul.push("<li>");
if (typeof value === "string") {
ul.push(`<a href=".${value}">${key}<small>.html</small></a>`);
} else {
if ("." in value) {
ul.push(`<strong><a href=".${value["."]}">${key}</a></strong>`);
delete value["."];
} else {
ul.push(`<strong><span>${key}</span></strong>`);
}
if (Reflect.ownKeys(value).length) ul.push(createList(value));
}
ul.push("</li>");
}
ul.push("</ul>");
return ul.join("");
};
writeFileSync(
TEST_INDEX,
`<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PyScript tests</title>
<style>
body { font-family: sans-serif; }
a {
display: block;
transition: opacity .3s;
}
a, span { opacity: .7; }
a:hover { opacity: 1; }
</style>
</head>
<body>${createList(crawl(TEST_DIR))}</body>
</html>
`,
);

View File

@@ -40,4 +40,17 @@ export default [
warn(warning);
},
},
{
input: "./src/storage.js",
plugins: plugins.concat(
process.env.NO_MIN
? [nodeResolve(), commonjs()]
: [nodeResolve(), commonjs(), terser()],
),
output: {
esModule: true,
dir: "./dist",
sourcemap: true,
},
},
];

View File

@@ -0,0 +1,17 @@
const { writeFileSync, readFileSync } = require("node:fs");
const { join } = require("node:path");
const flatted = "# https://www.npmjs.com/package/flatted\n\n";
const source = join(
__dirname,
"..",
"node_modules",
"flatted",
"python",
"flatted.py",
);
const dest = join(__dirname, "..", "src", "stdlib", "pyscript", "flatted.py");
const clear = (str) => String(str).replace(/^#.*/gm, "").trimStart();
writeFileSync(dest, flatted + clear(readFileSync(source)));

View File

@@ -4,13 +4,50 @@ const {
statSync,
writeFileSync,
} = require("node:fs");
const { spawnSync } = require("node:child_process");
const { join } = require("node:path");
const dedent = require("codedent");
const crawl = (path, json) => {
for (const file of readdirSync(path)) {
const full = join(path, file);
if (/\.py$/.test(file)) json[file] = readFileSync(full).toString();
else if (statSync(full).isDirectory() && !file.endsWith("_"))
if (/\.py$/.test(file)) {
if (process.env.NO_MIN) json[file] = readFileSync(full).toString();
else {
try {
const {
output: [error, result],
} = spawnSync("pyminify", [
"--remove-literal-statements",
full,
]);
if (error) {
console.error(error);
process.exit(1);
}
json[file] = result.toString();
} catch (error) {
console.error(error);
console.log(
dedent(`
\x1b[1m⚠ is your env activated?\x1b[0m
\x1b[2mYou need a Python env to run \x1b[0mpyminify\x1b[2m.\x1b[0m
\x1b[2mTo do so, you can try the following:\x1b[0m
python -m venv env
source env/bin/activate
pip install --upgrade pip
pip install --ignore-requires-python python-minifier
pip install setuptools
\x1b[2mand you can then try \x1b[0mnpm run build\x1b[2m again.\x1b[0m
`),
);
process.exit(1);
}
}
} else if (statSync(full).isDirectory() && !file.endsWith("_"))
crawl(full, (json[file] = {}));
}
};

View File

@@ -45,6 +45,8 @@ const configDetails = async (config, type) => {
const conflictError = (reason) => new Error(`(${CONFLICTING_CODE}): ${reason}`);
const relative_url = (url, base = location.href) => new URL(url, base).href;
const syntaxError = (type, url, { message }) => {
let str = `(${BAD_CONFIG}): Invalid ${type}`;
if (url) str += ` @ ${url}`;
@@ -108,7 +110,7 @@ for (const [TYPE] of TYPES) {
if (!error && config) {
try {
const { json, toml, text, url } = await configDetails(config, type);
if (url) configURL = new URL(url, location.href).href;
if (url) configURL = relative_url(url);
config = text;
if (json || type === "json") {
try {
@@ -153,4 +155,4 @@ for (const [TYPE] of TYPES) {
configs.set(TYPE, { config: parsed, configURL, plugins, error });
}
export default configs;
export { configs, relative_url };

View File

@@ -42,3 +42,34 @@ mpy-config {
.mpy-editor-run-button:disabled {
opacity: 1;
}
@keyframes spinner {
to {
transform: rotate(360deg);
}
}
.py-editor-run-button:disabled > *,
.mpy-editor-run-button:disabled > * {
display: none; /* hide all the child elements of the run button when it is disabled */
}
.py-editor-run-button:disabled,
.mpy-editor-run-button:disabled {
border-width: 0;
}
.py-editor-run-button:disabled::before,
.mpy-editor-run-button:disabled::before {
content: "";
box-sizing: border-box;
position: absolute;
top: 100%;
left: 100%;
width: 20px;
height: 20px;
margin-top: -23px; /* hardcoded value to center the spinner on the run button */
margin-left: -26px; /* hardcoded value to center the spinner on the run button */
border-radius: 50%;
border: 2px solid #aaa;
border-top-color: #000;
background-color: #fff;
animation: spinner 0.6s linear infinite;
}

View File

@@ -12,6 +12,7 @@ import {
define,
defineProperty,
dispatch,
isSync,
queryTarget,
unescape,
whenDefined,
@@ -19,15 +20,22 @@ import {
import "./all-done.js";
import TYPES from "./types.js";
import configs from "./config.js";
import { configs, relative_url } from "./config.js";
import sync from "./sync.js";
import bootstrapNodeAndPlugins from "./plugins-helper.js";
import { ErrorCode } from "./exceptions.js";
import { robustFetch as fetch, getText } from "./fetch.js";
import { hooks, main, worker, codeFor, createFunction } from "./hooks.js";
import {
hooks,
main,
worker,
codeFor,
createFunction,
inputFailure,
} from "./hooks.js";
import { stdlib, optional } from "./stdlib.js";
export { stdlib, optional };
export { stdlib, optional, inputFailure };
// generic helper to disambiguate between custom element and script
const isScript = ({ tagName }) => tagName === "SCRIPT";
@@ -77,6 +85,7 @@ const [
export {
TYPES,
relative_url,
exportedPyWorker as PyWorker,
exportedMPWorker as MPWorker,
exportedHooks as hooks,
@@ -85,7 +94,7 @@ export {
};
export const offline_interpreter = (config) =>
config?.interpreter && new URL(config.interpreter, location.href).href;
config?.interpreter && relative_url(config.interpreter);
const hooked = new Map();
@@ -150,6 +159,7 @@ for (const [TYPE, interpreter] of TYPES) {
// enrich the Python env with some JS utility for main
interpreter.registerJsModule("_pyscript", {
PyWorker,
js_import: (...urls) => Promise.all(urls.map((url) => import(url))),
get target() {
return isScript(currentElement)
? currentElement.target.id
@@ -193,15 +203,13 @@ for (const [TYPE, interpreter] of TYPES) {
}
if (isScript(element)) {
const {
attributes: { async: isAsync, target },
} = element;
const hasTarget = !!target?.value;
const show = hasTarget
? queryTarget(element, target.value)
const isAsync = !isSync(element);
const target = element.getAttribute("target");
const show = target
? queryTarget(element, target)
: document.createElement("script-py");
if (!hasTarget) {
if (!target) {
const { head, body } = document;
if (head.contains(element)) body.append(show);
else element.after(show);
@@ -322,7 +330,7 @@ for (const [TYPE, interpreter] of TYPES) {
async connectedCallback() {
if (!this.executed) {
this.executed = true;
const isAsync = this.hasAttribute("async");
const isAsync = !isSync(this);
const { io, run, runAsync } = await this._wrap
.promise;
this.srcCode = await fetchSource(

View File

@@ -46,7 +46,7 @@ export const createFunction = (self, name) => {
const SetFunction = typedSet({ typeof: "function" });
const SetString = typedSet({ typeof: "string" });
const inputFailure = `
export const inputFailure = `
import builtins
def input(prompt=""):
raise Exception("\\n ".join([

View File

@@ -1,6 +1,7 @@
// PyScript py-editor plugin
import { Hook, XWorker, dedent, defineProperties } from "polyscript/exports";
import { TYPES, offline_interpreter, stdlib } from "../core.js";
import { TYPES, offline_interpreter, relative_url, stdlib } from "../core.js";
import { notify } from "./error.js";
const RUN_BUTTON = `<svg style="height:20px;width:20px;vertical-align:-.125em;transform-origin:center;overflow:visible;color:green" viewBox="0 0 384 512" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg"><g transform="translate(192 256)" transform-origin="96 0"><g transform="translate(0,0) scale(1,1)"><path d="M361 215C375.3 223.8 384 239.3 384 256C384 272.7 375.3 288.2 361 296.1L73.03 472.1C58.21 482 39.66 482.4 24.52 473.9C9.377 465.4 0 449.4 0 432V80C0 62.64 9.377 46.63 24.52 38.13C39.66 29.64 58.21 29.99 73.03 39.04L361 215z" fill="currentColor" transform="translate(-192 -256)"></path></g></g></svg>`;
@@ -23,6 +24,11 @@ const hooks = {
},
};
const validate = (config, result) => {
if (typeof result === "boolean") throw `Invalid source: ${config}`;
return result;
};
async function execute({ currentTarget }) {
const { env, pySrc, outDiv } = this;
const hasRunButton = !!currentTarget;
@@ -34,15 +40,37 @@ async function execute({ currentTarget }) {
if (!envs.has(env)) {
const srcLink = URL.createObjectURL(new Blob([""]));
const details = { type: this.interpreter };
const details = {
type: this.interpreter,
serviceWorker: this.serviceWorker,
};
const { config } = this;
if (config) {
details.configURL = config;
const { parse } = config.endsWith(".toml")
? await import(/* webpackIgnore: true */ "../3rd-party/toml.js")
: JSON;
details.config = parse(await fetch(config).then((r) => r.text()));
details.version = offline_interpreter(details.config);
// verify that config can be parsed and used
try {
details.configURL = relative_url(config);
if (config.endsWith(".toml")) {
const [{ parse }, toml] = await Promise.all([
import(
/* webpackIgnore: true */ "../3rd-party/toml.js"
),
fetch(config).then((r) => r.ok && r.text()),
]);
details.config = parse(validate(config, toml));
} else if (config.endsWith(".json")) {
const json = await fetch(config).then(
(r) => r.ok && r.json(),
);
details.config = validate(config, json);
} else {
details.configURL = relative_url("./config.txt");
details.config = JSON.parse(config);
}
details.version = offline_interpreter(details.config);
} catch (error) {
notify(error);
return;
}
} else {
details.config = {};
}
@@ -63,9 +91,12 @@ async function execute({ currentTarget }) {
return envs.get(env).then((xworker) => {
xworker.onerror = ({ error }) => {
if (hasRunButton) {
outDiv.innerHTML += `<span style='color:red'>${
error.message || error
}</span>\n`;
outDiv.insertAdjacentHTML(
"beforeend",
`<span style='color:red'>${
error.message || error
}</span>\n`,
);
}
console.error(error);
};
@@ -76,31 +107,41 @@ async function execute({ currentTarget }) {
const { sync } = xworker;
sync.write = (str) => {
if (hasRunButton) outDiv.innerText += `${str}\n`;
else console.log(str);
};
sync.writeErr = (str) => {
if (hasRunButton) {
outDiv.innerHTML += `<span style='color:red'>${str}</span>\n`;
outDiv.insertAdjacentHTML(
"beforeend",
`<span style='color:red'>${str}</span>\n`,
);
} else {
notify(str);
console.error(str);
}
};
sync.runAsync(pySrc).then(enable, enable);
});
}
const makeRunButton = (listener, type) => {
const makeRunButton = (handler, type) => {
const runButton = document.createElement("button");
runButton.className = `absolute ${type}-editor-run-button`;
runButton.innerHTML = RUN_BUTTON;
runButton.setAttribute("aria-label", "Python Script Run Button");
runButton.addEventListener("click", listener);
runButton.addEventListener("click", async (event) => {
runButton.blur();
await handler.handleEvent(event);
});
return runButton;
};
const makeEditorDiv = (listener, type) => {
const makeEditorDiv = (handler, type) => {
const editorDiv = document.createElement("div");
editorDiv.className = `${type}-editor-input`;
editorDiv.setAttribute("aria-label", "Python Script Area");
const runButton = makeRunButton(listener, type);
const runButton = makeRunButton(handler, type);
const editorShadowContainer = document.createElement("div");
// avoid outer elements intercepting key events (reveal as example)
@@ -120,15 +161,15 @@ const makeOutDiv = (type) => {
return outDiv;
};
const makeBoxDiv = (listener, type) => {
const makeBoxDiv = (handler, type) => {
const boxDiv = document.createElement("div");
boxDiv.className = `${type}-editor-box`;
const editorDiv = makeEditorDiv(listener, type);
const editorDiv = makeEditorDiv(handler, type);
const outDiv = makeOutDiv(type);
boxDiv.append(editorDiv, outDiv);
return [boxDiv, outDiv];
return [boxDiv, outDiv, editorDiv.querySelector("button")];
};
const init = async (script, type, interpreter) => {
@@ -138,7 +179,7 @@ const init = async (script, type, interpreter) => {
{ python },
{ indentUnit },
{ keymap },
{ defaultKeymap },
{ defaultKeymap, indentWithTab },
] = await Promise.all([
import(/* webpackIgnore: true */ "../3rd-party/codemirror.js"),
import(/* webpackIgnore: true */ "../3rd-party/codemirror_state.js"),
@@ -152,8 +193,17 @@ const init = async (script, type, interpreter) => {
let isSetup = script.hasAttribute("setup");
const hasConfig = script.hasAttribute("config");
const serviceWorker = script.getAttribute("service-worker");
const env = `${interpreter}-${script.getAttribute("env") || getID(type)}`;
// helps preventing too lazy ServiceWorker initialization on button run
if (serviceWorker) {
new XWorker("data:application/javascript,postMessage(0)", {
type: "dummy",
serviceWorker,
}).onmessage = ({ target }) => target.terminate();
}
if (hasConfig && configs.has(env)) {
throw new SyntaxError(
configs.get(env)
@@ -164,15 +214,29 @@ const init = async (script, type, interpreter) => {
configs.set(env, hasConfig);
let source = script.src
? await fetch(script.src).then((b) => b.text())
: script.textContent;
let source = script.textContent;
// verify the src points to a valid file that can be parsed
const { src } = script;
if (src) {
try {
source = validate(
src,
await fetch(src).then((b) => b.ok && b.text()),
);
} catch (error) {
notify(error);
return;
}
}
const context = {
// allow the listener to be overridden at distance
handleEvent: execute,
serviceWorker,
interpreter,
env,
config:
hasConfig &&
new URL(script.getAttribute("config"), location.href).href,
config: hasConfig && script.getAttribute("config"),
get pySrc() {
return isSetup ? source : editor.state.doc.toString();
},
@@ -184,13 +248,53 @@ const init = async (script, type, interpreter) => {
let target;
defineProperties(script, {
target: { get: () => target },
handleEvent: {
get: () => context.handleEvent,
set: (callback) => {
// do not bother with logic if it was set back as its original handler
if (callback === execute) context.handleEvent = execute;
// in every other case be sure that if the listener override returned
// `false` nothing happens, otherwise keep doing what it always did
else {
context.handleEvent = async (event) => {
// trap the currentTarget ASAP (if any)
// otherwise it gets lost asynchronously
const { currentTarget } = event;
// augment a code snapshot before invoking the override
defineProperties(event, {
code: { value: context.pySrc },
});
// avoid executing the default handler if the override returned `false`
if ((await callback(event)) !== false)
await execute.call(context, { currentTarget });
};
}
},
},
code: {
get: () => context.pySrc,
set: (insert) => {
if (isSetup) return;
editor.update([
editor.state.update({
changes: {
from: 0,
to: editor.state.doc.length,
insert,
},
}),
]);
},
},
process: {
/**
* Simulate a setup node overriding the source to evaluate.
* @param {string} code the Python code to evaluate.
* @param {boolean} asRunButtonAction invoke the `Run` button handler.
* @returns {Promise<...>} fulfill once code has been evaluated.
*/
value(code) {
value(code, asRunButtonAction = false) {
if (asRunButtonAction) return listener();
const wasSetup = isSetup;
const wasSource = source;
isSetup = true;
@@ -199,21 +303,21 @@ const init = async (script, type, interpreter) => {
isSetup = wasSetup;
source = wasSource;
};
return execute
.call(context, { currentTarget: null })
return context
.handleEvent({ currentTarget: null })
.then(restore, restore);
},
},
});
const notify = () => {
const notifyEditor = () => {
const event = new Event(`${type}-editor`, { bubbles: true });
script.dispatchEvent(event);
};
if (isSetup) {
await execute.call(context, { currentTarget: null });
notify();
await context.handleEvent({ currentTarget: null });
notifyEditor();
return;
}
@@ -235,8 +339,7 @@ const init = async (script, type, interpreter) => {
if (!target.hasAttribute("root")) target.setAttribute("root", target.id);
// @see https://github.com/JeffersGlass/mkdocs-pyscript/blob/main/mkdocs_pyscript/js/makeblocks.js
const listener = execute.bind(context);
const [boxDiv, outDiv] = makeBoxDiv(listener, type);
const [boxDiv, outDiv, runButton] = makeBoxDiv(context, type);
boxDiv.dataset.env = script.hasAttribute("env") ? env : interpreter;
const inputChild = boxDiv.querySelector(`.${type}-editor-input > div`);
@@ -249,8 +352,9 @@ const init = async (script, type, interpreter) => {
const doc = dedent(script.textContent).trim();
// preserve user indentation, if any
const indentation = /^(\s+)/m.test(doc) ? RegExp.$1 : " ";
const indentation = /^([ \t]+)/m.test(doc) ? RegExp.$1 : " ";
const listener = () => runButton.click();
const editor = new EditorView({
extensions: [
indentUnit.of(indentation),
@@ -260,15 +364,19 @@ const init = async (script, type, interpreter) => {
{ key: "Ctrl-Enter", run: listener, preventDefault: true },
{ key: "Cmd-Enter", run: listener, preventDefault: true },
{ key: "Shift-Enter", run: listener, preventDefault: true },
// @see https://codemirror.net/examples/tab/
indentWithTab,
]),
basicSetup,
],
foldGutter: true,
gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"],
parent,
doc,
});
editor.focus();
notify();
notifyEditor();
};
// avoid too greedy MutationObserver operations at distance

View File

@@ -1,11 +1,14 @@
// PyScript py-terminal plugin
import { TYPES, hooks } from "../core.js";
import { TYPES, relative_url } from "../core.js";
import { notify } from "./error.js";
import { customObserver, defineProperties } from "polyscript/exports";
import { customObserver } from "polyscript/exports";
// will contain all valid selectors
const SELECTORS = [];
// avoid processing same elements twice
const processed = new WeakSet();
// show the error on main and
// stops the module from keep executing
const notifyAndThrow = (message) => {
@@ -15,265 +18,10 @@ const notifyAndThrow = (message) => {
const onceOnMain = ({ attributes: { worker } }) => !worker;
const bootstrapped = new WeakSet();
let addStyle = true;
// this callback will be serialized as string and it never needs
// to be invoked multiple times. Each xworker here is bootstrapped
// only once thanks to the `sync.is_pyterminal()` check.
const workerReady = ({ interpreter, io, run, type }, { sync }) => {
if (!sync.is_pyterminal()) return;
// in workers it's always safe to grab the polyscript currentScript
// the ugly `_` dance is due MicroPython not able to import via:
// `from polyscript.currentScript import terminal as __terminal__`
run(
"from polyscript import currentScript as _; __terminal__ = _.terminal; del _",
);
let data = "";
const { pyterminal_read, pyterminal_write } = sync;
const decoder = new TextDecoder();
const generic = {
isatty: false,
write(buffer) {
data = decoder.decode(buffer);
pyterminal_write(data);
return buffer.length;
},
};
// This part works already in both Pyodide and MicroPython
io.stderr = (error) => {
pyterminal_write(String(error.message || error));
};
// MicroPython has no code or code.interact()
// This part patches it in a way that simulates
// the code.interact() module in Pyodide.
if (type === "mpy") {
// monkey patch global input otherwise broken in MicroPython
interpreter.registerJsModule("_pyscript_input", {
input: pyterminal_read,
});
run("from _pyscript_input import input");
// this is needed to avoid truncated unicode in MicroPython
// the reason is that `linebuffer` false just send one byte
// per time and readline here doesn't like it much.
// MicroPython also has issues with code-points and
// replProcessChar(byte) but that function accepts only
// one byte per time so ... we have an issue!
// @see https://github.com/pyscript/pyscript/pull/2018
// @see https://github.com/WebReflection/buffer-points
const bufferPoints = (stdio) => {
const bytes = [];
let needed = 0;
return (buffer) => {
let written = 0;
for (const byte of buffer) {
bytes.push(byte);
// @see https://encoding.spec.whatwg.org/#utf-8-bytes-needed
if (needed) needed--;
else if (0xc2 <= byte && byte <= 0xdf) needed = 1;
else if (0xe0 <= byte && byte <= 0xef) needed = 2;
else if (0xf0 <= byte && byte <= 0xf4) needed = 3;
if (!needed) {
written += bytes.length;
stdio(new Uint8Array(bytes.splice(0)));
}
}
return written;
};
};
io.stdout = bufferPoints(generic.write);
// tiny shim of the code module with only interact
// to bootstrap a REPL like environment
interpreter.registerJsModule("code", {
interact() {
let input = "";
let length = 1;
const encoder = new TextEncoder();
const acc = [];
const handlePoints = bufferPoints((buffer) => {
acc.push(...buffer);
pyterminal_write(decoder.decode(buffer));
});
// avoid duplicating the output produced by the input
io.stdout = (buffer) =>
length++ > input.length ? handlePoints(buffer) : 0;
interpreter.replInit();
// loop forever waiting for user inputs
(function repl() {
const out = decoder.decode(new Uint8Array(acc.splice(0)));
// print in current line only the last line produced by the REPL
const data = `${pyterminal_read(out.split("\n").at(-1))}\r`;
length = 0;
input = encoder.encode(data);
for (const c of input) interpreter.replProcessChar(c);
repl();
})();
},
});
} else {
interpreter.setStdout(generic);
interpreter.setStderr(generic);
interpreter.setStdin({
isatty: false,
stdin: () => pyterminal_read(data),
});
}
};
const pyTerminal = async (element) => {
// lazy load these only when a valid terminal is found
const [{ Terminal }, { Readline }, { FitAddon }, { WebLinksAddon }] =
await Promise.all([
import(/* webpackIgnore: true */ "../3rd-party/xterm.js"),
import(/* webpackIgnore: true */ "../3rd-party/xterm-readline.js"),
import(/* webpackIgnore: true */ "../3rd-party/xterm_addon-fit.js"),
import(
/* webpackIgnore: true */ "../3rd-party/xterm_addon-web-links.js"
),
]);
const readline = new Readline();
// common main thread initialization for both worker
// or main case, bootstrapping the terminal on its target
const init = (options) => {
let target = element;
const selector = element.getAttribute("target");
if (selector) {
target =
document.getElementById(selector) ||
document.querySelector(selector);
if (!target) throw new Error(`Unknown target ${selector}`);
} else {
target = document.createElement("py-terminal");
target.style.display = "block";
element.after(target);
}
const terminal = new Terminal({
theme: {
background: "#191A19",
foreground: "#F5F2E7",
},
...options,
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(readline);
terminal.loadAddon(new WebLinksAddon());
terminal.open(target);
fitAddon.fit();
terminal.focus();
defineProperties(element, {
terminal: { value: terminal },
process: {
value: async (code) => {
// this loop is the only way I could find to actually simulate
// the user input char after char in a way that works in both
// MicroPython and Pyodide
for (const line of code.split(/(?:\r|\n|\r\n)/)) {
terminal.paste(`${line}\n`);
do {
await new Promise((resolve) =>
setTimeout(resolve, 0),
);
} while (!readline.activeRead?.resolve);
readline.activeRead.resolve(line);
}
},
},
});
return terminal;
};
// branch logic for the worker
if (element.hasAttribute("worker")) {
// add a hook on the main thread to setup all sync helpers
// also bootstrapping the XTerm target on main *BUT* ...
hooks.main.onWorker.add(function worker(_, xworker) {
// ... as multiple workers will add multiple callbacks
// be sure no xworker is ever initialized twice!
if (bootstrapped.has(xworker)) return;
bootstrapped.add(xworker);
// still cleanup this callback for future scripts/workers
hooks.main.onWorker.delete(worker);
init({
disableStdin: false,
cursorBlink: true,
cursorStyle: "block",
});
xworker.sync.is_pyterminal = () => true;
xworker.sync.pyterminal_read = readline.read.bind(readline);
xworker.sync.pyterminal_write = readline.write.bind(readline);
});
// setup remote thread JS/Python code for whenever the
// worker is ready to become a terminal
hooks.worker.onReady.add(workerReady);
} else {
// in the main case, just bootstrap XTerm without
// allowing any input as that's not possible / awkward
hooks.main.onReady.add(function main({ interpreter, io, run, type }) {
console.warn("py-terminal is read only on main thread");
hooks.main.onReady.delete(main);
// on main, it's easy to trash and clean the current terminal
globalThis.__py_terminal__ = init({
disableStdin: true,
cursorBlink: false,
cursorStyle: "underline",
});
run("from js import __py_terminal__ as __terminal__");
delete globalThis.__py_terminal__;
io.stderr = (error) => {
readline.write(String(error.message || error));
};
if (type === "mpy") {
interpreter.setStdin = Object; // as no-op
interpreter.setStderr = Object; // as no-op
interpreter.setStdout = ({ write }) => {
io.stdout = write;
};
}
let data = "";
const decoder = new TextDecoder();
const generic = {
isatty: false,
write(buffer) {
data = decoder.decode(buffer);
readline.write(data);
return buffer.length;
},
};
interpreter.setStdout(generic);
interpreter.setStderr(generic);
interpreter.setStdin({
isatty: false,
stdin: () => readline.read(data),
});
});
}
};
for (const key of TYPES.keys()) {
const selector = `script[type="${key}"][terminal],${key}-script[terminal]`;
for (const type of TYPES.keys()) {
const selector = `script[type="${type}"][terminal],${type}-script[terminal]`;
SELECTORS.push(selector);
customObserver.set(selector, async (element) => {
// we currently support only one terminal on main as in "classic"
@@ -287,11 +35,26 @@ for (const key of TYPES.keys()) {
document.head.append(
Object.assign(document.createElement("link"), {
rel: "stylesheet",
href: new URL("./xterm.css", import.meta.url),
href: relative_url("./xterm.css", import.meta.url),
}),
);
}
await pyTerminal(element);
if (processed.has(element)) return;
processed.add(element);
const bootstrap = (module) => module.default(element);
// we can't be smart with template literals for the dynamic import
// or bundlers are incapable of producing multiple files around
if (type === "mpy") {
await import(/* webpackIgnore: true */ "./py-terminal/mpy.js").then(
bootstrap,
);
} else {
await import(/* webpackIgnore: true */ "./py-terminal/py.js").then(
bootstrap,
);
}
});
}

View File

@@ -0,0 +1,252 @@
// PyScript pyodide terminal plugin
import { hooks, inputFailure } from "../../core.js";
import { defineProperties } from "polyscript/exports";
const bootstrapped = new WeakSet();
// this callback will be serialized as string and it never needs
// to be invoked multiple times. Each xworker here is bootstrapped
// only once thanks to the `sync.is_pyterminal()` check.
const workerReady = ({ interpreter, io, run, type }, { sync }) => {
if (type !== "mpy" || !sync.is_pyterminal()) return;
const { pyterminal_ready, pyterminal_read, pyterminal_write } = sync;
interpreter.registerJsModule("_pyscript_input", {
input: pyterminal_read,
});
run(
[
"from _pyscript_input import input",
"from polyscript import currentScript as _",
"__terminal__ = _.terminal",
"del _",
].join(";"),
);
const missingReturn = new Uint8Array([13]);
io.stdout = (buffer) => {
if (buffer[0] === 10) pyterminal_write(missingReturn);
pyterminal_write(buffer);
};
io.stderr = (error) => {
pyterminal_write(String(error.message || error));
};
// tiny shim of the code module with only interact
// to bootstrap a REPL like environment
interpreter.registerJsModule("code", {
interact() {
const encoder = new TextEncoderStream();
encoder.readable.pipeTo(
new WritableStream({
write(buffer) {
for (const c of buffer) interpreter.replProcessChar(c);
},
}),
);
const writer = encoder.writable.getWriter();
sync.pyterminal_stream_write = (buffer) => writer.write(buffer);
interpreter.replInit();
},
});
pyterminal_ready();
};
export default async (element) => {
// lazy load these only when a valid terminal is found
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
import(/* webpackIgnore: true */ "../../3rd-party/xterm.js"),
import(/* webpackIgnore: true */ "../../3rd-party/xterm_addon-fit.js"),
import(
/* webpackIgnore: true */ "../../3rd-party/xterm_addon-web-links.js"
),
]);
const terminalOptions = {
disableStdin: false,
cursorBlink: true,
cursorStyle: "block",
};
let stream;
// common main thread initialization for both worker
// or main case, bootstrapping the terminal on its target
const init = () => {
let target = element;
const selector = element.getAttribute("target");
if (selector) {
target =
document.getElementById(selector) ||
document.querySelector(selector);
if (!target) throw new Error(`Unknown target ${selector}`);
} else {
target = document.createElement("py-terminal");
target.style.display = "block";
element.after(target);
}
const terminal = new Terminal({
theme: {
background: "#191A19",
foreground: "#F5F2E7",
},
...terminalOptions,
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(new WebLinksAddon());
terminal.open(target);
fitAddon.fit();
terminal.focus();
defineProperties(element, {
terminal: { value: terminal },
process: {
value: async (code) => {
for (const line of code.split(/(?:\r\n|\r|\n)/)) {
await stream.write(`${line}\r`);
}
},
},
});
return terminal;
};
// branch logic for the worker
if (element.hasAttribute("worker")) {
// add a hook on the main thread to setup all sync helpers
// also bootstrapping the XTerm target on main *BUT* ...
hooks.main.onWorker.add(function worker(_, xworker) {
// ... as multiple workers will add multiple callbacks
// be sure no xworker is ever initialized twice!
if (bootstrapped.has(xworker)) return;
bootstrapped.add(xworker);
// still cleanup this callback for future scripts/workers
hooks.main.onWorker.delete(worker);
const terminal = init();
const { sync } = xworker;
// handle the read mode on input
let promisedChunks = null;
let readChunks = "";
sync.is_pyterminal = () => true;
// put the terminal in a read-only state
// frees the worker on \r
sync.pyterminal_read = (buffer) => {
terminal.write(buffer);
promisedChunks = Promise.withResolvers();
return promisedChunks.promise;
};
// write if not reading input
sync.pyterminal_write = (buffer) => {
if (!promisedChunks) terminal.write(buffer);
};
// add the onData terminal listener which forwards to the worker
// everything typed in a queued char-by-char way
sync.pyterminal_ready = () => {
let queue = Promise.resolve();
stream = {
write: (buffer) =>
(queue = queue.then(() =>
sync.pyterminal_stream_write(buffer),
)),
};
terminal.onData((buffer) => {
if (promisedChunks) {
// handle backspace on input
if (buffer === "\x7f") {
// avoid over-greedy backspace
if (readChunks.length) {
readChunks = readChunks.slice(0, -1);
// override previous char position
// put an empty space to clear the char
// move back position again
buffer = "\b \b";
} else buffer = "";
} else readChunks += buffer;
if (buffer) {
terminal.write(buffer);
if (readChunks.endsWith("\r")) {
terminal.write("\n");
promisedChunks.resolve(readChunks.slice(0, -1));
promisedChunks = null;
readChunks = "";
}
}
} else {
stream.write(buffer);
}
});
};
});
// setup remote thread JS/Python code for whenever the
// worker is ready to become a terminal
hooks.worker.onReady.add(workerReady);
} else {
// ⚠️ In an ideal world the inputFailure should never be used on main.
// However, Pyodide still can't compete with MicroPython REPL mode
// so while it's OK to keep that entry on main as default, we need
// to remove it ASAP from `mpy` use cases, otherwise MicroPython would
// also throw whenever an `input(...)` is required / digited.
hooks.main.codeBeforeRun.delete(inputFailure);
// in the main case, just bootstrap XTerm without
// allowing any input as that's not possible / awkward
hooks.main.onReady.add(function main({ interpreter, io, run, type }) {
if (type !== "mpy") return;
hooks.main.onReady.delete(main);
const terminal = init();
const missingReturn = new Uint8Array([13]);
io.stdout = (buffer) => {
if (buffer[0] === 10) terminal.write(missingReturn);
terminal.write(buffer);
};
// expose the __terminal__ one-off reference
globalThis.__py_terminal__ = terminal;
run(
[
"from js import prompt as input",
"from js import __py_terminal__ as __terminal__",
].join(";"),
);
delete globalThis.__py_terminal__;
// NOTE: this is NOT the same as the one within
// the onWorkerReady callback!
interpreter.registerJsModule("code", {
interact() {
const encoder = new TextEncoderStream();
encoder.readable.pipeTo(
new WritableStream({
write(buffer) {
for (const c of buffer)
interpreter.replProcessChar(c);
},
}),
);
stream = encoder.writable.getWriter();
terminal.onData((buffer) => stream.write(buffer));
interpreter.replInit();
},
});
});
}
};

View File

@@ -0,0 +1,179 @@
// PyScript py-terminal plugin
import { hooks } from "../../core.js";
import { defineProperties } from "polyscript/exports";
const bootstrapped = new WeakSet();
// this callback will be serialized as string and it never needs
// to be invoked multiple times. Each xworker here is bootstrapped
// only once thanks to the `sync.is_pyterminal()` check.
const workerReady = ({ interpreter, io, run, type }, { sync }) => {
if (type !== "py" || !sync.is_pyterminal()) return;
run(
[
"from polyscript import currentScript as _",
"__terminal__ = _.terminal",
"del _",
].join(";"),
);
let data = "";
const { pyterminal_read, pyterminal_write } = sync;
const decoder = new TextDecoder();
const generic = {
isatty: false,
write(buffer) {
data = decoder.decode(buffer);
pyterminal_write(data);
return buffer.length;
},
};
io.stderr = (error) => {
pyterminal_write(String(error.message || error));
};
interpreter.setStdout(generic);
interpreter.setStderr(generic);
interpreter.setStdin({
isatty: false,
stdin: () => pyterminal_read(data),
});
};
export default async (element) => {
// lazy load these only when a valid terminal is found
const [{ Terminal }, { Readline }, { FitAddon }, { WebLinksAddon }] =
await Promise.all([
import(/* webpackIgnore: true */ "../../3rd-party/xterm.js"),
import(
/* webpackIgnore: true */ "../../3rd-party/xterm-readline.js"
),
import(
/* webpackIgnore: true */ "../../3rd-party/xterm_addon-fit.js"
),
import(
/* webpackIgnore: true */ "../../3rd-party/xterm_addon-web-links.js"
),
]);
const readline = new Readline();
// common main thread initialization for both worker
// or main case, bootstrapping the terminal on its target
const init = (options) => {
let target = element;
const selector = element.getAttribute("target");
if (selector) {
target =
document.getElementById(selector) ||
document.querySelector(selector);
if (!target) throw new Error(`Unknown target ${selector}`);
} else {
target = document.createElement("py-terminal");
target.style.display = "block";
element.after(target);
}
const terminal = new Terminal({
theme: {
background: "#191A19",
foreground: "#F5F2E7",
},
...options,
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(readline);
terminal.loadAddon(new WebLinksAddon());
terminal.open(target);
fitAddon.fit();
terminal.focus();
defineProperties(element, {
terminal: { value: terminal },
process: {
value: async (code) => {
for (const line of code.split(/(?:\r\n|\r|\n)/)) {
terminal.paste(`${line}`);
terminal.write("\r\n");
do {
await new Promise((resolve) =>
setTimeout(resolve, 0),
);
} while (!readline.activeRead?.resolve);
readline.activeRead.resolve(line);
}
},
},
});
return terminal;
};
// branch logic for the worker
if (element.hasAttribute("worker")) {
// add a hook on the main thread to setup all sync helpers
// also bootstrapping the XTerm target on main *BUT* ...
hooks.main.onWorker.add(function worker(_, xworker) {
// ... as multiple workers will add multiple callbacks
// be sure no xworker is ever initialized twice!
if (bootstrapped.has(xworker)) return;
bootstrapped.add(xworker);
// still cleanup this callback for future scripts/workers
hooks.main.onWorker.delete(worker);
init({
disableStdin: false,
cursorBlink: true,
cursorStyle: "block",
});
xworker.sync.is_pyterminal = () => true;
xworker.sync.pyterminal_read = readline.read.bind(readline);
xworker.sync.pyterminal_write = readline.write.bind(readline);
});
// setup remote thread JS/Python code for whenever the
// worker is ready to become a terminal
hooks.worker.onReady.add(workerReady);
} else {
// in the main case, just bootstrap XTerm without
// allowing any input as that's not possible / awkward
hooks.main.onReady.add(function main({ interpreter, io, run, type }) {
if (type !== "py") return;
console.warn("py-terminal is read only on main thread");
hooks.main.onReady.delete(main);
// on main, it's easy to trash and clean the current terminal
globalThis.__py_terminal__ = init({
disableStdin: true,
cursorBlink: false,
cursorStyle: "underline",
});
run("from js import __py_terminal__ as __terminal__");
delete globalThis.__py_terminal__;
io.stderr = (error) => {
readline.write(String(error.message || error));
};
let data = "";
const decoder = new TextDecoder();
const generic = {
isatty: false,
write(buffer) {
data = decoder.decode(buffer);
readline.write(data);
return buffer.length;
},
};
interpreter.setStdout(generic);
interpreter.setStderr(generic);
interpreter.setStdin({
isatty: false,
stdin: () => readline.read(data),
});
});
}
};

View File

@@ -37,7 +37,7 @@ const python = [
"_path = None",
];
const ignore = new Ignore(python, "./pyweb");
const ignore = new Ignore(python, "-");
const write = (base, literal) => {
for (const [key, value] of entries(literal)) {

View File

@@ -29,6 +29,7 @@
# pyscript.magic_js. This is the blessed way to access them from pyscript,
# as it works transparently in both the main thread and worker cases.
from polyscript import lazy_py_modules as py_import
from pyscript.display import HTML, display
from pyscript.fetch import fetch
from pyscript.magic_js import (
@@ -37,12 +38,17 @@ from pyscript.magic_js import (
config,
current_target,
document,
js_import,
js_modules,
sync,
window,
)
from pyscript.storage import Storage, storage
from pyscript.websocket import WebSocket
if not RUNNING_IN_WORKER:
from pyscript.workers import create_named_worker, workers
try:
from pyscript.event_handling import when
except:

View File

@@ -19,41 +19,51 @@ def when(event_type=None, selector=None):
"""
def decorator(func):
from pyscript.web import Element, ElementCollection
if isinstance(selector, str):
elements = document.querySelectorAll(selector)
# TODO: This is a hack that will be removed when pyscript becomes a package
# and we can better manage the imports without circular dependencies
elif isinstance(selector, Element):
elements = [selector._dom_element]
elif isinstance(selector, ElementCollection):
elements = [el._dom_element for el in selector]
else:
# TODO: This is a hack that will be removed when pyscript becomes a package
# and we can better manage the imports without circular dependencies
from pyweb import pydom
if isinstance(selector, pydom.Element):
elements = [selector._js]
elif isinstance(selector, pydom.ElementCollection):
elements = [el._js for el in selector]
if isinstance(selector, list):
elements = selector
else:
raise ValueError(
f"Invalid selector: {selector}. Selector must"
" be a string, a pydom.Element or a pydom.ElementCollection."
)
elements = [selector]
try:
sig = inspect.signature(func)
# Function doesn't receive events
if not sig.parameters:
def wrapper(*args, **kwargs):
func()
# Function is async: must be awaited
if inspect.iscoroutinefunction(func):
async def wrapper(*args, **kwargs):
await func()
else:
def wrapper(*args, **kwargs):
func()
else:
wrapper = func
except AttributeError:
# TODO: this is currently an quick hack to get micropython working but we need
# to actually properly replace inspect.signature with something else
# TODO: this is very ugly hack to get micropython working because inspect.signature
# doesn't exist, but we need to actually properly replace inspect.signature.
# It may be actually better to not try any magic for now and raise the error
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except TypeError as e:
if "takes 0 positional arguments" in str(e):
if "takes" in str(e) and "positional arguments" in str(e):
return func()
raise

View File

@@ -0,0 +1,148 @@
# https://www.npmjs.com/package/flatted
import json as _json
class _Known:
def __init__(self):
self.key = []
self.value = []
class _String:
def __init__(self, value):
self.value = value
def _array_keys(value):
keys = []
i = 0
for _ in value:
keys.append(i)
i += 1
return keys
def _object_keys(value):
keys = []
for key in value:
keys.append(key)
return keys
def _is_array(value):
return isinstance(value, list) or isinstance(value, tuple)
def _is_object(value):
return isinstance(value, dict)
def _is_string(value):
return isinstance(value, str)
def _index(known, input, value):
input.append(value)
index = str(len(input) - 1)
known.key.append(value)
known.value.append(index)
return index
def _loop(keys, input, known, output):
for key in keys:
value = output[key]
if isinstance(value, _String):
_ref(key, input[int(value.value)], input, known, output)
return output
def _ref(key, value, input, known, output):
if _is_array(value) and not value in known:
known.append(value)
value = _loop(_array_keys(value), input, known, value)
elif _is_object(value) and not value in known:
known.append(value)
value = _loop(_object_keys(value), input, known, value)
output[key] = value
def _relate(known, input, value):
if _is_string(value) or _is_array(value) or _is_object(value):
try:
return known.value[known.key.index(value)]
except:
return _index(known, input, value)
return value
def _transform(known, input, value):
if _is_array(value):
output = []
for val in value:
output.append(_relate(known, input, val))
return output
if _is_object(value):
obj = {}
for key in value:
obj[key] = _relate(known, input, value[key])
return obj
return value
def _wrap(value):
if _is_string(value):
return _String(value)
if _is_array(value):
i = 0
for val in value:
value[i] = _wrap(val)
i += 1
elif _is_object(value):
for key in value:
value[key] = _wrap(value[key])
return value
def parse(value, *args, **kwargs):
json = _json.loads(value, *args, **kwargs)
wrapped = []
for value in json:
wrapped.append(_wrap(value))
input = []
for value in wrapped:
if isinstance(value, _String):
input.append(value.value)
else:
input.append(value)
value = input[0]
if _is_array(value):
return _loop(_array_keys(value), input, [value], value)
if _is_object(value):
return _loop(_object_keys(value), input, [value], value)
return value
def stringify(value, *args, **kwargs):
known = _Known()
input = []
output = []
i = int(_index(known, input, value))
while i < len(input):
output.append(_transform(known, input, input[i]))
i += 1
return _json.dumps(output, *args, **kwargs)

View File

@@ -10,6 +10,11 @@ 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:
@@ -36,24 +41,21 @@ if RUNNING_IN_WORKER:
)
try:
globalThis.SharedArrayBuffer.new(4)
import js
window = polyscript.xworker.window
document = window.document
js.document = document
# this is the same as js_import on main and it lands modules on main
js_import = window.Function(
"return (...urls) => Promise.all(urls.map((url) => import(url)))"
)()
except:
globalThis.console.debug("SharedArrayBuffer is not available")
# in this scenario none of the utilities would work
# as expected so we better export these as NotSupported
window = NotSupported(
"pyscript.window",
"pyscript.window in workers works only via SharedArrayBuffer",
)
document = NotSupported(
"pyscript.document",
"pyscript.document in workers works only via SharedArrayBuffer",
)
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
@@ -64,7 +66,7 @@ if RUNNING_IN_WORKER:
else:
import _pyscript
from _pyscript import PyWorker
from _pyscript import PyWorker, js_import
window = globalThis
document = globalThis.document

View File

@@ -0,0 +1,60 @@
from polyscript import storage as _storage
from pyscript.flatted import parse as _parse
from pyscript.flatted import stringify as _stringify
# convert a Python value into an IndexedDB compatible entry
def _to_idb(value):
if value is None:
return _stringify(["null", 0])
if isinstance(value, (bool, float, int, str, list, dict, tuple)):
return _stringify(["generic", value])
if isinstance(value, bytearray):
return _stringify(["bytearray", [v for v in value]])
if isinstance(value, memoryview):
return _stringify(["memoryview", [v for v in value]])
raise TypeError(f"Unexpected value: {value}")
# convert an IndexedDB compatible entry into a Python value
def _from_idb(value):
(
kind,
result,
) = _parse(value)
if kind == "null":
return None
if kind == "generic":
return result
if kind == "bytearray":
return bytearray(result)
if kind == "memoryview":
return memoryview(bytearray(result))
return value
class Storage(dict):
def __init__(self, store):
super().__init__({k: _from_idb(v) for k, v in store.entries()})
self.__store__ = store
def __delitem__(self, attr):
self.__store__.delete(attr)
super().__delitem__(attr)
def __setitem__(self, attr, value):
self.__store__.set(attr, _to_idb(value))
super().__setitem__(attr, value)
def clear(self):
self.__store__.clear()
super().clear()
async def sync(self):
await self.__store__.sync()
async def storage(name="", storage_class=Storage):
if not name:
raise ValueError("The storage name must be defined")
return storage_class(await _storage(f"@pyscript/{name}"))

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,11 @@
import js
from pyscript.ffi import create_proxy
from pyscript.util import as_bytearray
code = "code"
protocols = "protocols"
reason = "reason"
methods = ["onclose", "onerror", "onmessage", "onopen"]
class EventMessage:
@@ -36,18 +38,20 @@ class WebSocket(object):
socket = js.WebSocket.new(url)
object.__setattr__(self, "_ws", socket)
for t in ["onclose", "onerror", "onmessage", "onopen"]:
for t in methods:
if t in kw:
socket[t] = kw[t]
# Pyodide fails at setting socket[t] directly
setattr(socket, t, create_proxy(kw[t]))
def __getattr__(self, attr):
return getattr(self._ws, attr)
def __setattr__(self, attr, value):
if attr == "onmessage":
self._ws[attr] = lambda e: value(EventMessage(e))
if attr in methods:
m = lambda e: value(EventMessage(e))
setattr(self._ws, attr, create_proxy(m))
else:
self._ws[attr] = value
setattr(self._ws, attr, value)
def close(self, **kw):
if code in kw and reason in kw:

View File

@@ -0,0 +1,43 @@
import js as _js
from polyscript import workers as _workers
_get = _js.Reflect.get
def _set(script, name, value=""):
script.setAttribute(name, value)
# this solves an inconsistency between Pyodide and MicroPython
# @see https://github.com/pyscript/pyscript/issues/2106
class _ReadOnlyProxy:
def __getitem__(self, name):
return _get(_workers, name)
def __getattr__(self, name):
return _get(_workers, name)
workers = _ReadOnlyProxy()
async def create_named_worker(src="", name="", config=None, type="py"):
from json import dumps
if not src:
raise ValueError("Named workers require src")
if not name:
raise ValueError("Named workers require a name")
s = _js.document.createElement("script")
s.type = type
s.src = src
_set(s, "worker")
_set(s, "name", name)
if config:
_set(s, "config", isinstance(config, str) and config or dumps(config))
_js.document.body.append(s)
return await workers[name]

View File

@@ -1 +0,0 @@
from .pydom import dom as pydom

View File

@@ -1,95 +0,0 @@
from pyodide.ffi import to_js
from pyscript import window
class Device:
"""Device represents a media input or output device, such as a microphone,
camera, or headset.
"""
def __init__(self, device):
self._js = device
@property
def id(self):
return self._js.deviceId
@property
def group(self):
return self._js.groupId
@property
def kind(self):
return self._js.kind
@property
def label(self):
return self._js.label
def __getitem__(self, key):
return getattr(self, key)
@classmethod
async def load(cls, audio=False, video=True):
"""Load the device stream."""
options = window.Object.new()
options.audio = audio
if isinstance(video, bool):
options.video = video
else:
# TODO: Think this can be simplified but need to check it on the pyodide side
# TODO: this is pyodide specific. shouldn't be!
options.video = window.Object.new()
for k in video:
setattr(
options.video,
k,
to_js(video[k], dict_converter=window.Object.fromEntries),
)
stream = await window.navigator.mediaDevices.getUserMedia(options)
return stream
async def get_stream(self):
key = self.kind.replace("input", "").replace("output", "")
options = {key: {"deviceId": {"exact": self.id}}}
return await self.load(**options)
async def list_devices() -> list[dict]:
"""
Return the list of the currently available media input and output devices,
such as microphones, cameras, headsets, and so forth.
Output:
list(dict) - list of dictionaries representing the available media devices.
Each dictionary has the following keys:
* deviceId: a string that is an identifier for the represented device
that is persisted across sessions. It is un-guessable by other
applications and unique to the origin of the calling application.
It is reset when the user clears cookies (for Private Browsing, a
different identifier is used that is not persisted across sessions).
* groupId: a string that is a group identifier. Two devices have the same
group identifier if they belong to the same physical device — for
example a monitor with both a built-in camera and a microphone.
* kind: an enumerated value that is either "videoinput", "audioinput"
or "audiooutput".
* label: a string describing this device (for example "External USB
Webcam").
Note: the returned list will omit any devices that are blocked by the document
Permission Policy: microphone, camera, speaker-selection (for output devices),
and so on. Access to particular non-default devices is also gated by the
Permissions API, and the list will omit devices for which the user has not
granted explicit permission.
"""
# https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices
return [
Device(obj) for obj in await window.navigator.mediaDevices.enumerateDevices()
]

View File

@@ -1,550 +0,0 @@
try:
from typing import Any
except ImportError:
Any = "Any"
try:
import warnings
except ImportError:
# TODO: For now it probably means we are in MicroPython. We should figure
# out the "right" way to handle this. For now we just ignore the warning
# and logging to console
class warnings:
@staticmethod
def warn(*args, **kwargs):
print("WARNING: ", *args, **kwargs)
try:
from functools import cached_property
except ImportError:
# TODO: same comment about micropython as above
cached_property = property
try:
from pyodide.ffi import JsProxy
except ImportError:
# TODO: same comment about micropython as above
def JsProxy(obj):
return obj
from pyscript import display, document, window
alert = window.alert
class BaseElement:
def __init__(self, js_element):
self._js = js_element
self._parent = None
self.style = StyleProxy(self)
self._proxies = {}
def __eq__(self, obj):
"""Check if the element is the same as the other element by comparing
the underlying JS element"""
return isinstance(obj, BaseElement) and obj._js == self._js
@property
def parent(self):
if self._parent:
return self._parent
if self._js.parentElement:
self._parent = self.__class__(self._js.parentElement)
return self._parent
@property
def __class(self):
return self.__class__ if self.__class__ != PyDom else Element
def create(self, type_, is_child=True, classes=None, html=None, label=None):
js_el = document.createElement(type_)
element = self.__class(js_el)
if classes:
for class_ in classes:
element.add_class(class_)
if html is not None:
element.html = html
if label is not None:
element.label = label
if is_child:
self.append(element)
return element
def find(self, selector):
"""Return an ElementCollection representing all the child elements that
match the specified selector.
Args:
selector (str): A string containing a selector expression
Returns:
ElementCollection: A collection of elements matching the selector
"""
elements = self._js.querySelectorAll(selector)
if not elements:
return None
return ElementCollection([Element(el) for el in elements])
class Element(BaseElement):
@property
def children(self):
return [self.__class__(el) for el in self._js.children]
def append(self, child):
# TODO: this is Pyodide specific for now!!!!!!
# if we get passed a JSProxy Element directly we just map it to the
# higher level Python element
if isinstance(child, JsProxy):
return self.append(Element(child))
elif isinstance(child, Element):
self._js.appendChild(child._js)
return child
elif isinstance(child, ElementCollection):
for el in child:
self.append(el)
# -------- Pythonic Interface to Element -------- #
@property
def html(self):
return self._js.innerHTML
@html.setter
def html(self, value):
self._js.innerHTML = value
@property
def text(self):
return self._js.textContent
@text.setter
def text(self, value):
self._js.textContent = value
@property
def content(self):
# TODO: This breaks with with standard template elements. Define how to best
# handle this specifica use case. Just not support for now?
if self._js.tagName == "TEMPLATE":
warnings.warn(
"Content attribute not supported for template elements.", stacklevel=2
)
return None
return self._js.innerHTML
@content.setter
def content(self, value):
# TODO: (same comment as above)
if self._js.tagName == "TEMPLATE":
warnings.warn(
"Content attribute not supported for template elements.", stacklevel=2
)
return
display(value, target=self.id)
@property
def id(self):
return self._js.id
@id.setter
def id(self, value):
self._js.id = value
@property
def options(self):
if "options" in self._proxies:
return self._proxies["options"]
if not self._js.tagName.lower() in {"select", "datalist", "optgroup"}:
raise AttributeError(
f"Element {self._js.tagName} has no options attribute."
)
self._proxies["options"] = OptionsProxy(self)
return self._proxies["options"]
@property
def value(self):
return self._js.value
@value.setter
def value(self, value):
# in order to avoid confusion to the user, we don't allow setting the
# value of elements that don't have a value attribute
if not hasattr(self._js, "value"):
raise AttributeError(
f"Element {self._js.tagName} has no value attribute. If you want to "
"force a value attribute, set it directly using the `_js.value = <value>` "
"javascript API attribute instead."
)
self._js.value = value
@property
def selected(self):
return self._js.selected
@selected.setter
def selected(self, value):
# in order to avoid confusion to the user, we don't allow setting the
# value of elements that don't have a value attribute
if not hasattr(self._js, "selected"):
raise AttributeError(
f"Element {self._js.tagName} has no value attribute. If you want to "
"force a value attribute, set it directly using the `_js.value = <value>` "
"javascript API attribute instead."
)
self._js.selected = value
def clone(self, new_id=None):
clone = Element(self._js.cloneNode(True))
clone.id = new_id
return clone
def remove_class(self, classname):
classList = self._js.classList
if isinstance(classname, list):
classList.remove(*classname)
else:
classList.remove(classname)
return self
def add_class(self, classname):
classList = self._js.classList
if isinstance(classname, list):
classList.add(*classname)
else:
self._js.classList.add(classname)
return self
@property
def classes(self):
classes = self._js.classList.values()
return [x for x in classes]
def show_me(self):
self._js.scrollIntoView()
def snap(
self,
to: BaseElement | str = None,
width: int | None = None,
height: int | None = None,
):
"""
Captures a snapshot of a video element. (Only available for video elements)
Inputs:
* to: element where to save the snapshot of the video frame to
* width: width of the image
* height: height of the image
Output:
(Element) canvas element where the video frame snapshot was drawn into
"""
if self._js.tagName != "VIDEO":
raise AttributeError("Snap method is only available for video Elements")
if to is None:
canvas = self.create("canvas")
if width is None:
width = self._js.width
if height is None:
height = self._js.height
canvas._js.width = width
canvas._js.height = height
elif isistance(to, Element):
if to._js.tagName != "CANVAS":
raise TypeError("Element to snap to must a canvas.")
canvas = to
elif getattr(to, "tagName", "") == "CANVAS":
canvas = Element(to)
elif isinstance(to, str):
canvas = pydom[to][0]
if canvas._js.tagName != "CANVAS":
raise TypeError("Element to snap to must a be canvas.")
canvas.draw(self, width, height)
return canvas
def download(self, filename: str = "snapped.png") -> None:
"""Download the current element (only available for canvas elements) with the filename
provided in input.
Inputs:
* filename (str): name of the file being downloaded
Output:
None
"""
if self._js.tagName != "CANVAS":
raise AttributeError(
"The download method is only available for canvas Elements"
)
link = self.create("a")
link._js.download = filename
link._js.href = self._js.toDataURL()
link._js.click()
def draw(self, what, width, height):
"""Draw `what` on the current element (only available for canvas elements).
Inputs:
* what (canvas image source): An element to draw into the context. The specification permits any canvas
image source, specifically, an HTMLImageElement, an SVGImageElement, an HTMLVideoElement,
an HTMLCanvasElement, an ImageBitmap, an OffscreenCanvas, or a VideoFrame.
"""
if self._js.tagName != "CANVAS":
raise AttributeError(
"The draw method is only available for canvas Elements"
)
if isinstance(what, Element):
what = what._js
# https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage
self._js.getContext("2d").drawImage(what, 0, 0, width, height)
class OptionsProxy:
"""This class represents the options of a select element. It
allows to access to add and remove options by using the `add` and `remove` methods.
"""
def __init__(self, element: Element) -> None:
self._element = element
if self._element._js.tagName.lower() != "select":
raise AttributeError(
f"Element {self._element._js.tagName} has no options attribute."
)
def add(
self,
value: Any = None,
html: str = None,
text: str = None,
before: Element | int = None,
**kws,
) -> None:
"""Add a new option to the select element"""
# create the option element and set the attributes
option = document.createElement("option")
if value is not None:
kws["value"] = value
if html is not None:
option.innerHTML = html
if text is not None:
kws["text"] = text
for key, value in kws.items():
option.setAttribute(key, value)
if before:
if isinstance(before, Element):
before = before._js
self._element._js.add(option, before)
def remove(self, item: int) -> None:
"""Remove the option at the specified index"""
self._element._js.remove(item)
def clear(self) -> None:
"""Remove all the options"""
for i in range(len(self)):
self.remove(0)
@property
def options(self):
"""Return the list of options"""
return [Element(opt) for opt in self._element._js.options]
@property
def selected(self):
"""Return the selected option"""
return self.options[self._element._js.selectedIndex]
def __iter__(self):
yield from self.options
def __len__(self):
return len(self.options)
def __repr__(self):
return f"{self.__class__.__name__} (length: {len(self)}) {self.options}"
def __getitem__(self, key):
return self.options[key]
class StyleProxy: # (dict):
def __init__(self, element: Element) -> None:
self._element = element
@cached_property
def _style(self):
return self._element._js.style
def __getitem__(self, key):
return self._style.getPropertyValue(key)
def __setitem__(self, key, value):
self._style.setProperty(key, value)
def remove(self, key):
self._style.removeProperty(key)
def set(self, **kws):
for k, v in kws.items():
self._element._js.style.setProperty(k, v)
# CSS Properties
# Reference: https://github.com/microsoft/TypeScript/blob/main/src/lib/dom.generated.d.ts#L3799C1-L5005C2
# Following prperties automatically generated from the above reference using
# tools/codegen_css_proxy.py
@property
def visible(self):
return self._element._js.style.visibility
@visible.setter
def visible(self, value):
self._element._js.style.visibility = value
class StyleCollection:
def __init__(self, collection: "ElementCollection") -> None:
self._collection = collection
def __get__(self, obj, objtype=None):
return obj._get_attribute("style")
def __getitem__(self, key):
return self._collection._get_attribute("style")[key]
def __setitem__(self, key, value):
for element in self._collection._elements:
element.style[key] = value
def remove(self, key):
for element in self._collection._elements:
element.style.remove(key)
class ElementCollection:
def __init__(self, elements: [Element]) -> None:
self._elements = elements
self.style = StyleCollection(self)
def __getitem__(self, key):
# If it's an integer we use it to access the elements in the collection
if isinstance(key, int):
return self._elements[key]
# If it's a slice we use it to support slice operations over the elements
# in the collection
elif isinstance(key, slice):
return ElementCollection(self._elements[key])
# If it's anything else (basically a string) we use it as a selector
# TODO: Write tests!
elements = self._element.querySelectorAll(key)
return ElementCollection([Element(el) for el in elements])
def __len__(self):
return len(self._elements)
def __eq__(self, obj):
"""Check if the element is the same as the other element by comparing
the underlying JS element"""
return isinstance(obj, ElementCollection) and obj._elements == self._elements
def _get_attribute(self, attr, index=None):
if index is None:
return [getattr(el, attr) for el in self._elements]
# As JQuery, when getting an attr, only return it for the first element
return getattr(self._elements[index], attr)
def _set_attribute(self, attr, value):
for el in self._elements:
setattr(el, attr, value)
@property
def html(self):
return self._get_attribute("html")
@html.setter
def html(self, value):
self._set_attribute("html", value)
@property
def value(self):
return self._get_attribute("value")
@value.setter
def value(self, value):
self._set_attribute("value", value)
@property
def children(self):
return self._elements
def __iter__(self):
yield from self._elements
def __repr__(self):
return f"{self.__class__.__name__} (length: {len(self._elements)}) {self._elements}"
class DomScope:
def __getattr__(self, __name: str):
element = document[f"#{__name}"]
if element:
return element[0]
class PyDom(BaseElement):
# Add objects we want to expose to the DOM namespace since this class instance is being
# remapped as "the module" itself
BaseElement = BaseElement
Element = Element
ElementCollection = ElementCollection
def __init__(self):
# PyDom is a special case of BaseElement where we don't want to create a new JS element
# and it really doesn't have a need for styleproxy or parent to to call to __init__
# (which actually fails in MP for some reason)
self._js = document
self._parent = None
self._proxies = {}
self.ids = DomScope()
self.body = Element(document.body)
self.head = Element(document.head)
def create(self, type_, classes=None, html=None):
return super().create(type_, is_child=False, classes=classes, html=html)
def __getitem__(self, key):
elements = self._js.querySelectorAll(key)
if not elements:
return None
return ElementCollection([Element(el) for el in elements])
dom = PyDom()

View File

@@ -0,0 +1,72 @@
import { ArrayBuffer, TypedArray } from "sabayon/shared";
import IDBMapSync from "@webreflection/idb-map/sync";
import { parse, stringify } from "flatted";
const to_idb = (value) => {
if (value == null) return stringify(["null", 0]);
/* eslint-disable no-fallthrough */
switch (typeof value) {
case "object": {
if (value instanceof TypedArray)
return stringify(["memoryview", [...value]]);
if (value instanceof ArrayBuffer)
return stringify(["bytearray", [...new Uint8Array(value)]]);
}
case "string":
case "number":
case "boolean":
return stringify(["generic", value]);
default:
throw new TypeError(`Unexpected value: ${String(value)}`);
}
};
const from_idb = (value) => {
const [kind, result] = parse(value);
if (kind === "null") return null;
if (kind === "generic") return result;
if (kind === "bytearray") return new Uint8Array(value).buffer;
if (kind === "memoryview") return new Uint8Array(value);
return value;
};
// this export simulate pyscript.storage exposed in the Python world
export const storage = async (name) => {
if (!name) throw new SyntaxError("The storage name must be defined");
const store = new IDBMapSync(`@pyscript/${name}`);
const map = new Map();
await store.sync();
for (const [k, v] of store.entries()) map.set(k, from_idb(v));
const clear = () => {
map.clear();
store.clear();
};
const sync = async () => {
await store.sync();
};
return new Proxy(map, {
ownKeys: (map) => [...map.keys()],
has: (map, name) => map.has(name),
get: (map, name) => {
if (name === "clear") return clear;
if (name === "sync") return sync;
return map.get(name);
},
set: (map, name, value) => {
map.set(name, value);
store.set(name, to_idb(value));
return true;
},
deleteProperty: (map, name) => {
if (map.has(name)) {
map.delete(name);
store.delete(name);
}
return true;
},
});
};

View File

@@ -1,4 +0,0 @@
{
"status": "passed",
"failedTests": []
}

View File

@@ -1,15 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<link rel="stylesheet" href="../dist/core.css">
<script type="module" src="../dist/core.js"></script>
</head>
<body>
<py-script async>
import asyncio
print('foo')
await asyncio.sleep(1)
print('bar')
</py-script>
</body>
</html>

View File

@@ -1,39 +0,0 @@
// PyScript Error Plugin
import { hooks } from '@pyscript/core';
hooks.onBeforeRun.add(function override(pyScript) {
// be sure this override happens only once
hooks.onBeforeRun.delete(override);
// trap generic `stderr` to propagate to it regardless
const { stderr } = pyScript.io;
// override it with our own logic
pyScript.io.stderr = (...args) => {
// grab the message of the first argument (Error)
const [ { message } ] = args;
// show it
notify(message);
// still let other plugins or PyScript itself do the rest
return stderr(...args);
};
});
// Error hook utilities
// Custom function to show notifications
function notify(message) {
const div = document.createElement('div');
div.textContent = message;
div.style.cssText = `
border: 1px solid red;
background: #ffdddd;
color: black;
font-family: courier, monospace;
white-space: pre;
overflow-x: auto;
padding: 8px;
margin-top: 8px;
`;
document.body.append(div);
}

View File

@@ -1,90 +0,0 @@
import { test, expect } from '@playwright/test';
test('MicroPython display', async ({ page }) => {
await page.goto('http://localhost:8080/test/mpy.html');
await page.waitForSelector('html.done.worker');
const body = await page.evaluate(() => document.body.innerText);
await expect(body.trim()).toBe([
'M-PyScript Main 1',
'M-PyScript Main 2',
'M-PyScript Worker',
].join('\n'));
});
test('MicroPython hooks', async ({ page }) => {
const logs = [];
page.on('console', msg => {
const text = msg.text();
if (!text.startsWith('['))
logs.push(text);
});
await page.goto('http://localhost:8080/test/hooks.html');
await page.waitForSelector('html.done.worker');
await expect(logs.join('\n')).toBe([
'main onReady',
'main onBeforeRun',
'main codeBeforeRun',
'actual code in main',
'main codeAfterRun',
'main onAfterRun',
'worker onReady',
'worker onBeforeRun',
'worker codeBeforeRun',
'actual code in worker',
'worker codeAfterRun',
'worker onAfterRun',
].join('\n'));
});
test('MicroPython + Pyodide js_modules', async ({ page }) => {
const logs = [];
page.on('console', msg => {
const text = msg.text();
if (!text.startsWith('['))
logs.push(text);
});
await page.goto('http://localhost:8080/test/js_modules.html');
await page.waitForSelector('html.done');
await expect(logs.length).toBe(6);
await expect(logs[0]).toBe(logs[1]);
await expect(logs[1]).toBe(logs[2]);
await expect(logs[3]).toBe(logs[4]);
await expect(logs[4]).toBe(logs[5]);
});
test('MicroPython + configURL', async ({ page }) => {
const logs = [];
page.on('console', msg => {
const text = msg.text();
if (!text.startsWith('['))
logs.push(text);
});
await page.goto('http://localhost:8080/test/config-url.html');
await page.waitForSelector('html.main.worker');
});
test('Pyodide + terminal on Main', async ({ page }) => {
await page.goto('http://localhost:8080/test/py-terminal-main.html');
await page.waitForSelector('html.ok');
});
test('Pyodide + terminal on Worker', async ({ page }) => {
await page.goto('http://localhost:8080/test/py-terminal-worker.html');
await page.waitForSelector('html.ok');
});
test('Pyodide + multiple terminals via Worker', async ({ page }) => {
await page.goto('http://localhost:8080/test/py-terminals.html');
await page.waitForSelector('html.first.second');
});
test('MicroPython + Pyodide fetch', async ({ page }) => {
await page.goto('http://localhost:8080/test/fetch.html');
await page.waitForSelector('html.mpy.py');
});
test('MicroPython + Pyodide ffi', async ({ page }) => {
await page.goto('http://localhost:8080/test/ffi.html');
await page.waitForSelector('html.mpy.py');
});

View File

@@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PyDom Example</title>
<link rel="stylesheet" href="../dist/core.css">
<script type="module" src="../dist/core.js"></script>
</head>
<body>
<script type="py" src="pydom.py"></script>
<button id="just-a-button">Click For Time</button>
<button id="color-button">Click For Color</button>
<button id="color-reset-button">Reset Color</button>
<div id="result"></div>
</body>
</html>

View File

@@ -1,33 +0,0 @@
import random
import time
from datetime import datetime as dt
from pyscript import display, when
from pyweb import pydom
@when("click", "#just-a-button")
def on_click():
try:
timenow = dt.now()
except NotImplementedError:
# In this case we assume it's not implemented because we are using MycroPython
tnow = time.localtime()
tstr = "{:02d}/{:02d}/{:04d} {:02d}:{:02d}:{:02d}"
timenow = tstr.format(tnow[2], tnow[1], tnow[0], *tnow[2:])
display(f"Hello from PyScript, time is: {timenow}", append=False, target="result")
@when("click", "#color-button")
def on_color_click(event):
btn = pydom["#result"]
btn.style["background-color"] = f"#{random.randrange(0x1000000):06x}"
@when("click", "#color-reset-button")
def reset_color(*args, **kwargs):
pydom["#result"].style["background-color"] = "white"
# btn_reset = pydom["#color-reset-button"][0].when('click', reset_color)

View File

@@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PyDom Example (MicroPython)</title>
<link rel="stylesheet" href="../dist/core.css">
<script type="module" src="../dist/core.js"></script>
</head>
<body>
<script type="mpy" src="pydom.py"></script>
<button id="just-a-button">Click For Time</button>
<button id="color-button">Click For Color</button>
<button id="color-reset-button">Reset Color</button>
<div id="result"></div>
</body>
</html>

View File

@@ -1,130 +0,0 @@
<html lang="en">
<head>
<title>PyDom Test Suite</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="../../dist/core.css">
<script type="module" src="../../dist/core.js"></script>
<style>
@import url("https://fonts.googleapis.com/css?family=Roboto:100,400");
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
*:before, *:after {
box-sizing: inherit;
-webkit-font-smoothing: antialiased;
}
body {
font-family: "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace;
font-size: 14px; font-style: normal; font-variant: normal; font-weight: 400; line-height: 20px;
}
h1 { font-size: 24px; font-weight: 700; line-height: 26.4px; }
h2 { font-size: 14px; font-weight: 700; line-height: 15.4px; }
#tests-terminal{
padding: 20px;
}
</style>
</head>
<body>
<script type="py" src="./run_tests.py" config="./tests.toml"></script>
<h1>pyscript.dom Tests</h1>
<p>You can pass test parameters to this test suite by passing them as query params on the url.
For instance, to pass "-v -s --pdb" to pytest, you would use the following url:
<label style="color: blue">?-v&-s&--pdb</label>
</p>
<div id="tests-terminal"></div>
<template id="test_card_with_element_template">
<p>This is a test. {foo}</p>
</template>
<div id="test_id_selector" style="visibility: hidden;">You found test_id_selector</div>
<div id="test_class_selector" class="a-test-class" style="visibility: hidden;">You found test_class_selector</div>
<div id="test_selector_w_children" class="a-test-class" style="visibility: hidden;">
<div id="test_selector_w_children_child_1" class="a-test-class" style="visibility: hidden;">Child 1</div>
<div id="test_selector_w_children_child_2" style="visibility: hidden;">Child 2</div>
</div>
<div id="div-no-classes"></div>
<div style="visibility: hidden;">
<h2>Test Read and Write</h2>
<div id="test_rr_div">Content test_rr_div</div>
<h3 id="test_rr_h3">Content test_rr_h3</h3>
<div id="multi-elem-div" class="multi-elems">Content multi-elem-div</div>
<p id="multi-elem-p" class="multi-elems">Content multi-elem-p</p>
<h2 id="multi-elem-h2" class="multi-elems">Content multi-elem-h2</h2>
<form>
<input id="test_rr_input_text" type="text" value="Content test_rr_input_text">
<input id="test_rr_input_button" type="button" value="Content test_rr_input_button">
<input id="test_rr_input_email" type="email" value="Content test_rr_input_email">
<input id="test_rr_input_password" type="password" value="Content test_rr_input_password">
</form>
<select id="test_select_element"></select>
<select id="test_select_element_w_options">
<option value="1">Option 1</option>
<option value="2" selected="selected">Option 2</option>
</select>
<select id="test_select_element_to_clear">
<option value="1">Option 1</option>
<option value="2">Option 2</option>
<option value="4">Option 4</option>
</select>
<select id="test_select_element_to_remove">
<option value="1">Option 1</option>
<option value="2">Option 2</option>
<option value="3">Option 3</option>
<option value="4">Option 4</option>
</select>
<div id="element-creation-test"></div>
<button id="a-test-button">I'm a button to be clicked</button>
<button>I'm another button you can click</button>
<button id="a-third-button">2 is better than 3 :)</button>
<div id="element-append-tests"></div>
<p class="collection"></p>
<div class="collection"></div>
<h3 class="collection"></h3>
<div id="element_attribute_tests"></div>
</div>
<script defer>
console.log("remapping console.log")
const terminalDiv = document.getElementById("tests-terminal");
const log = console.log.bind(console)
let testsStarted = false;
console.log = (...args) => {
let txt = args.join(" ");
let token = "<br>";
if (txt.endsWith("FAILED"))
token = " ❌<br>";
else if (txt.endsWith("PASSED"))
token = " ✅<br>";
if (testsStarted)
terminalDiv.innerHTML += args.join(" ") + token;
log(...args)
// if we got the flag that tests are starting, then we can start logging
if (args.join(" ") == "tests starting")
testsStarted = true;
}
</script>
</body>
</html>

View File

@@ -1,7 +0,0 @@
print("tests starting")
import pytest
from pyscript import window
args = window.location.search.replace("?", "").split("&")
pytest.main(args)

View File

@@ -1,8 +0,0 @@
packages = [
"pytest"
]
[[fetch]]
from = "tests/"
files = ["__init__.py", "conftest.py", "test_dom.py"]
to_folder = "tests"

View File

@@ -1,15 +0,0 @@
import pytest
from js import document, localStorage
@pytest.fixture(autouse=True)
def before_tests():
"""
Ensure browser storage is always reset to empty. Remove the app
placeholder. Reset the page title.
"""
localStorage.clear()
# app_placeholder = document.querySelector("pyper-app")
# if app_placeholder:
# app_placeholder.remove()
document.querySelector("title").innerText = "Web API PyTest Suite"

View File

@@ -1,459 +0,0 @@
from unittest import mock
import pytest
from pyscript import document, when
from pyweb import pydom
class TestDocument:
def test__element(self):
assert pydom._js == document
def test_no_parent(self):
assert pydom.parent is None
def test_create_element(self):
new_el = pydom.create("div")
assert isinstance(new_el, pydom.BaseElement)
assert new_el._js.tagName == "DIV"
# EXPECT the new element to be associated with the document
assert new_el.parent == None
def test_getitem_by_id():
# GIVEN an existing element on the page with a known text content
id_ = "test_id_selector"
txt = "You found test_id_selector"
selector = f"#{id_}"
# EXPECT the element to be found by id
result = pydom[selector]
div = result[0]
# EXPECT the element text value to match what we expect and what
# the JS document.querySelector API would return
assert document.querySelector(selector).innerHTML == div.html == txt
# EXPECT the results to be of the right types
assert isinstance(div, pydom.BaseElement)
assert isinstance(result, pydom.ElementCollection)
def test_getitem_by_class():
ids = [
"test_class_selector",
"test_selector_w_children",
"test_selector_w_children_child_1",
]
expected_class = "a-test-class"
result = pydom[f".{expected_class}"]
div = result[0]
# EXPECT to find exact number of elements with the class in the page (== 3)
assert len(result) == 3
# EXPECT that all element ids are in the expected list
assert [el.id for el in result] == ids
def test_read_n_write_collection_elements():
elements = pydom[".multi-elems"]
for element in elements:
assert element.html == f"Content {element.id.replace('#', '')}"
new_content = "New Content"
elements.html = new_content
for element in elements:
assert element.html == new_content
class TestElement:
def test_query(self):
# GIVEN an existing element on the page, with at least 1 child element
id_ = "test_selector_w_children"
parent_div = pydom[f"#{id_}"][0]
# EXPECT it to be able to query for the first child element
div = parent_div.find("div")[0]
# EXPECT the new element to be associated with the parent
assert div.parent == parent_div
# EXPECT the new element to be a BaseElement
assert isinstance(div, pydom.BaseElement)
# EXPECT the div attributes to be == to how they are configured in the page
assert div.html == "Child 1"
assert div.id == "test_selector_w_children_child_1"
def test_equality(self):
# GIVEN 2 different Elements pointing to the same underlying element
id_ = "test_id_selector"
selector = f"#{id_}"
div = pydom[selector][0]
div2 = pydom[selector][0]
# EXPECT them to be equal
assert div == div2
# EXPECT them to be different objects
assert div is not div2
# EXPECT their value to always be equal
assert div.html == div2.html
div.html = "some value"
assert div.html == div2.html == "some value"
def test_append_element(self):
id_ = "element-append-tests"
div = pydom[f"#{id_}"][0]
len_children_before = len(div.children)
new_el = div.create("p")
div.append(new_el)
assert len(div.children) == len_children_before + 1
assert div.children[-1] == new_el
def test_append_js_element(self):
id_ = "element-append-tests"
div = pydom[f"#{id_}"][0]
len_children_before = len(div.children)
new_el = div.create("p")
div.append(new_el._js)
assert len(div.children) == len_children_before + 1
assert div.children[-1] == new_el
def test_append_collection(self):
id_ = "element-append-tests"
div = pydom[f"#{id_}"][0]
len_children_before = len(div.children)
collection = pydom[".collection"]
div.append(collection)
assert len(div.children) == len_children_before + len(collection)
for i in range(len(collection)):
assert div.children[-1 - i] == collection[-1 - i]
def test_read_classes(self):
id_ = "test_class_selector"
expected_class = "a-test-class"
div = pydom[f"#{id_}"][0]
assert div.classes == [expected_class]
def test_add_remove_class(self):
id_ = "div-no-classes"
classname = "tester-class"
div = pydom[f"#{id_}"][0]
assert not div.classes
div.add_class(classname)
same_div = pydom[f"#{id_}"][0]
assert div.classes == [classname] == same_div.classes
div.remove_class(classname)
assert div.classes == [] == same_div.classes
def test_when_decorator(self):
called = False
just_a_button = pydom["#a-test-button"][0]
@when("click", just_a_button)
def on_click(event):
nonlocal called
called = True
# Now let's simulate a click on the button (using the low level JS API)
# so we don't risk pydom getting in the way
assert not called
just_a_button._js.click()
assert called
def test_html_attribute(self):
# GIVEN an existing element on the page with a known empty text content
div = pydom["#element_attribute_tests"][0]
# WHEN we set the html attribute
div.html = "<b>New Content</b>"
# EXPECT the element html and underlying JS Element innerHTML property
# to match what we expect and what
assert div.html == div._js.innerHTML == "<b>New Content</b>"
assert div.text == div._js.textContent == "New Content"
def test_text_attribute(self):
# GIVEN an existing element on the page with a known empty text content
div = pydom["#element_attribute_tests"][0]
# WHEN we set the html attribute
div.text = "<b>New Content</b>"
# EXPECT the element html and underlying JS Element innerHTML property
# to match what we expect and what
assert div.html == div._js.innerHTML == "&lt;b&gt;New Content&lt;/b&gt;"
assert div.text == div._js.textContent == "<b>New Content</b>"
class TestCollection:
def test_iter_eq_children(self):
elements = pydom[".multi-elems"]
assert [el for el in elements] == [el for el in elements.children]
assert len(elements) == 3
def test_slices(self):
elements = pydom[".multi-elems"]
assert elements[0]
_slice = elements[:2]
assert len(_slice) == 2
for i, el in enumerate(_slice):
assert el == elements[i]
assert elements[:] == elements
def test_style_rule(self):
selector = ".multi-elems"
elements = pydom[selector]
for el in elements:
assert el.style["background-color"] != "red"
elements.style["background-color"] = "red"
for i, el in enumerate(pydom[selector]):
assert elements[i].style["background-color"] == "red"
assert el.style["background-color"] == "red"
elements.style.remove("background-color")
for i, el in enumerate(pydom[selector]):
assert el.style["background-color"] != "red"
assert elements[i].style["background-color"] != "red"
def test_when_decorator(self):
called = False
buttons_collection = pydom["button"]
@when("click", buttons_collection)
def on_click(event):
nonlocal called
called = True
# Now let's simulate a click on the button (using the low level JS API)
# so we don't risk pydom getting in the way
assert not called
for button in buttons_collection:
button._js.click()
assert called
called = False
class TestCreation:
def test_create_document_element(self):
new_el = pydom.create("div")
new_el.id = "new_el_id"
assert isinstance(new_el, pydom.BaseElement)
assert new_el._js.tagName == "DIV"
# EXPECT the new element to be associated with the document
assert new_el.parent == None
pydom.body.append(new_el)
assert pydom["#new_el_id"][0].parent == pydom.body
def test_create_element_child(self):
selector = "#element-creation-test"
parent_div = pydom[selector][0]
# Creating an element from another element automatically creates that element
# as a child of the original element
new_el = parent_div.create(
"p", classes=["code-description"], html="Ciao PyScripters!"
)
assert isinstance(new_el, pydom.BaseElement)
assert new_el._js.tagName == "P"
# EXPECT the new element to be associated with the document
assert new_el.parent == parent_div
assert pydom[selector][0].children[0] == new_el
class TestInput:
input_ids = [
"test_rr_input_text",
"test_rr_input_button",
"test_rr_input_email",
"test_rr_input_password",
]
def test_value(self):
for id_ in self.input_ids:
expected_type = id_.split("_")[-1]
result = pydom[f"#{id_}"]
input_el = result[0]
assert input_el._js.type == expected_type
assert input_el.value == f"Content {id_}" == input_el._js.value
# Check that we can set the value
new_value = f"New Value {expected_type}"
input_el.value = new_value
assert input_el.value == new_value
# Check that we can set the value back to the original using
# the collection
new_value = f"Content {id_}"
result.value = new_value
assert input_el.value == new_value
def test_set_value_collection(self):
for id_ in self.input_ids:
input_el = pydom[f"#{id_}"]
assert input_el.value[0] == f"Content {id_}" == input_el[0].value
new_value = f"New Value {id_}"
input_el.value = new_value
assert input_el.value[0] == new_value == input_el[0].value
def test_element_without_value(self):
result = pydom[f"#tests-terminal"][0]
with pytest.raises(AttributeError):
result.value = "some value"
def test_element_without_collection(self):
result = pydom[f"#tests-terminal"]
with pytest.raises(AttributeError):
result.value = "some value"
def test_element_without_collection(self):
result = pydom[f"#tests-terminal"]
with pytest.raises(AttributeError):
result.value = "some value"
class TestSelect:
def test_select_options_iter(self):
select = pydom[f"#test_select_element_w_options"][0]
for i, option in enumerate(select.options, 1):
assert option.value == f"{i}"
assert option.html == f"Option {i}"
def test_select_options_len(self):
select = pydom[f"#test_select_element_w_options"][0]
assert len(select.options) == 2
def test_select_options_clear(self):
select = pydom[f"#test_select_element_to_clear"][0]
assert len(select.options) == 3
select.options.clear()
assert len(select.options) == 0
def test_select_element_add(self):
# GIVEN the existing select element with no options
select = pydom[f"#test_select_element"][0]
# EXPECT the select element to have no options
assert len(select.options) == 0
# WHEN we add an option
select.options.add(value="1", html="Option 1")
# EXPECT the select element to have 1 option matching the attributes
# we passed in
assert len(select.options) == 1
assert select.options[0].value == "1"
assert select.options[0].html == "Option 1"
# WHEN we add another option (blank this time)
select.options.add("")
# EXPECT the select element to have 2 options
assert len(select.options) == 2
# EXPECT the last option to have an empty value and html
assert select.options[1].value == ""
assert select.options[1].html == ""
# WHEN we add another option (this time adding it in between the other 2
# options by using an integer index)
select.options.add(value="2", html="Option 2", before=1)
# EXPECT the select element to have 3 options
assert len(select.options) == 3
# EXPECT the middle option to have the value and html we passed in
assert select.options[0].value == "1"
assert select.options[0].html == "Option 1"
assert select.options[1].value == "2"
assert select.options[1].html == "Option 2"
assert select.options[2].value == ""
assert select.options[2].html == ""
# WHEN we add another option (this time adding it in between the other 2
# options but using the option itself)
select.options.add(
value="3", html="Option 3", before=select.options[2], selected=True
)
# EXPECT the select element to have 3 options
assert len(select.options) == 4
# EXPECT the middle option to have the value and html we passed in
assert select.options[0].value == "1"
assert select.options[0].html == "Option 1"
assert select.options[0].selected == select.options[0]._js.selected == False
assert select.options[1].value == "2"
assert select.options[1].html == "Option 2"
assert select.options[2].value == "3"
assert select.options[2].html == "Option 3"
assert select.options[2].selected == select.options[2]._js.selected == True
assert select.options[3].value == ""
assert select.options[3].html == ""
# WHEN we add another option (this time adding it in between the other 2
# options but using the JS element of the option itself)
select.options.add(value="2a", html="Option 2a", before=select.options[2]._js)
# EXPECT the select element to have 3 options
assert len(select.options) == 5
# EXPECT the middle option to have the value and html we passed in
assert select.options[0].value == "1"
assert select.options[0].html == "Option 1"
assert select.options[1].value == "2"
assert select.options[1].html == "Option 2"
assert select.options[2].value == "2a"
assert select.options[2].html == "Option 2a"
assert select.options[3].value == "3"
assert select.options[3].html == "Option 3"
assert select.options[4].value == ""
assert select.options[4].html == ""
def test_select_options_remove(self):
# GIVEN the existing select element with 3 options
select = pydom[f"#test_select_element_to_remove"][0]
# EXPECT the select element to have 3 options
assert len(select.options) == 4
# EXPECT the options to have the values originally set
assert select.options[0].value == "1"
assert select.options[1].value == "2"
assert select.options[2].value == "3"
assert select.options[3].value == "4"
# WHEN we remove the second option (index starts at 0)
select.options.remove(1)
# EXPECT the select element to have 2 options
assert len(select.options) == 3
# EXPECT the options to have the values originally set but the second
assert select.options[0].value == "1"
assert select.options[1].value == "3"
assert select.options[2].value == "4"
def test_select_get_selected_option(self):
# GIVEN the existing select element with one selected option
select = pydom[f"#test_select_element_w_options"][0]
# WHEN we get the selected option
selected_option = select.options.selected
# EXPECT the selected option to be correct
assert selected_option.value == "2"
assert selected_option.html == "Option 2"
assert selected_option.selected == selected_option._js.selected == True

View File

@@ -0,0 +1,49 @@
# PyScript Test Suite
There are three aspects to our test suite. These are reflected in the layout of
the test directory:
1. `python` - contains the Python based test suite to exercise Python code
**within** PyScript. These tests are run four differeng ways to ensure all
combination of MicroPython/Pyodide and main thread/worker contexts are
checked.
2. `javascript` - contains JavaScript tests to exercise PyScript _itself_, in
the browser.
3. `manual` - contains tests to run manually in a browser, due to the complex
nature of the tests.
We use [Playwright](https://playwright.dev/) to automate the running of the
Python and JavaScript test suites. We use
[uPyTest](https://github.com/ntoll/upytest) as a test framework for the Python
test suite. uPyTest is a "PyTest inspired" framework for running tests in the
browser on both MicroPython and Pyodide.
The automated (Playwright) tests are specified in the `integration.spec.js`
file in this directory.
All automatic tests live in either the `python` or `javascript` folders. All
the tests in these folder are run by CI or locally run by `make test` in the
root of this project. Alternatively, run `npm run test:integration` in the
PyScript source directory.
Similarly, some tests can only be run manually (due to their nature or
underlying complexity). These are in the `manual` directory and are in the form
of separate directories (each containing an `index.html`) or individual `*.html`
files to which you point your browser. Each separate test may exercise
JavaScript or Python code (or both), and the context for each separate test is
kept carefully isolated.
Some rules of thumb:
* We don't test upstream projects: we assume they have their own test suites,
and if we find bugs, we file an issue upstream with an example of how to
recreate the problem.
* We don't test browser functionality, we just have to trust that browsers work
as advertised. Once again, if we find an issue, we report upstream.
* All test cases should include commentary describing the **intent** and
context of the test.
* Tests in Python use [uPyTest](https://github.com/ntoll/upytest) (see the
README for documentation), an "inspired by PyTest" test framework that works
with both MicroPython and Pyodide in the browser. This means that all
Python tests should work with both interpreters.
* Tests in JavaScript... (Andrea to explain). ;-)

View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PyScript tests</title>
<style>
body { font-family: sans-serif; }
a {
display: block;
transition: opacity .3s;
}
a, span { opacity: .7; }
a:hover { opacity: 1; }
</style>
</head>
<body><ul><li><strong><a href="./config/index.html">config</a></strong><ul><li><a href="./config/ambiguous-config.html">ambiguous-config<small>.html</small></a></li><li><a href="./config/same-config.html">same-config<small>.html</small></a></li><li><a href="./config/too-many-config.html">too-many-config<small>.html</small></a></li><li><a href="./config/too-many-py-config.html">too-many-py-config<small>.html</small></a></li></ul></li><li><strong><a href="./issue-7015/index.html">issue-7015</a></strong></li><li><strong><span>javascript</span></strong><ul><li><a href="./javascript/async-listener.html">async-listener<small>.html</small></a></li><li><a href="./javascript/config-url.html">config-url<small>.html</small></a></li><li><a href="./javascript/config_type.html">config_type<small>.html</small></a></li><li><strong><a href="./javascript/fetch/index.html">fetch</a></strong></li><li><a href="./javascript/ffi.html">ffi<small>.html</small></a></li><li><a href="./javascript/hooks.html">hooks<small>.html</small></a></li><li><strong><a href="./javascript/issue-2093/index.html">issue-2093</a></strong></li><li><a href="./javascript/js-storage.html">js-storage<small>.html</small></a></li><li><a href="./javascript/js_modules.html">js_modules<small>.html</small></a></li><li><strong><a href="./javascript/loader/index.html">loader</a></strong></li><li><a href="./javascript/mpy.html">mpy<small>.html</small></a></li><li><a href="./javascript/py-terminal-main.html">py-terminal-main<small>.html</small></a></li><li><a href="./javascript/py-terminal-worker.html">py-terminal-worker<small>.html</small></a></li><li><a href="./javascript/py-terminal.html">py-terminal<small>.html</small></a></li><li><a href="./javascript/py-terminals.html">py-terminals<small>.html</small></a></li><li><a href="./javascript/storage.html">storage<small>.html</small></a></li><li><strong><a href="./javascript/workers/index.html">workers</a></strong><ul><li><a href="./javascript/workers/named.html">named<small>.html</small></a></li></ul></li></ul></li><li><strong><a href="./manual/index.html">manual</a></strong><ul><li><a href="./manual/all-done.html">all-done<small>.html</small></a></li><li><a href="./manual/async.html">async<small>.html</small></a></li><li><a href="./manual/camera.html">camera<small>.html</small></a></li><li><a href="./manual/click.html">click<small>.html</small></a></li><li><a href="./manual/code-a-part.html">code-a-part<small>.html</small></a></li><li><a href="./manual/combo.html">combo<small>.html</small></a></li><li><a href="./manual/config.html">config<small>.html</small></a></li><li><a href="./manual/create-element.html">create-element<small>.html</small></a></li><li><a href="./manual/dialog.html">dialog<small>.html</small></a></li><li><a href="./manual/display.html">display<small>.html</small></a></li><li><a href="./manual/error.html">error<small>.html</small></a></li><li><a href="./manual/html-decode.html">html-decode<small>.html</small></a></li><li><a href="./manual/input.html">input<small>.html</small></a></li><li><a href="./manual/interpreter.html">interpreter<small>.html</small></a></li><li><a href="./manual/multi.html">multi<small>.html</small></a></li><li><a href="./manual/multiple-editors.html">multiple-editors<small>.html</small></a></li><li><a href="./manual/no-error.html">no-error<small>.html</small></a></li><li><a href="./manual/py-editor-failure.html">py-editor-failure<small>.html</small></a></li><li><a href="./manual/py-editor.html">py-editor<small>.html</small></a></li><li><a href="./manual/py_modules.html">py_modules<small>.html</small></a></li><li><a href="./manual/split-config.html">split-config<small>.html</small></a></li><li><a href="./manual/submit.html">submit<small>.html</small></a></li><li><a href="./manual/target.html">target<small>.html</small></a></li><li><a href="./manual/test_display_HTML.html">test_display_HTML<small>.html</small></a></li><li><a href="./manual/test_when.html">test_when<small>.html</small></a></li><li><a href="./manual/worker.html">worker<small>.html</small></a></li></ul></li><li><strong><a href="./no_sab/index.html">no_sab</a></strong></li><li><strong><a href="./piratical/index.html">piratical</a></strong></li><li><strong><a href="./py-editor/index.html">py-editor</a></strong><ul><li><a href="./py-editor/issue-2056.html">issue-2056<small>.html</small></a></li><li><a href="./py-editor/service-worker.html">service-worker<small>.html</small></a></li></ul></li><li><strong><a href="./py-terminals/index.html">py-terminals</a></strong><ul><li><a href="./py-terminals/no-repl.html">no-repl<small>.html</small></a></li><li><a href="./py-terminals/repl.html">repl<small>.html</small></a></li></ul></li><li><strong><a href="./python/index.html">python</a></strong></li><li><strong><a href="./service-worker/index.html">service-worker</a></strong></li></ul></body>
</html>

View File

@@ -1,183 +0,0 @@
import shutil
import threading
from http.server import HTTPServer as SuperHTTPServer
from http.server import SimpleHTTPRequestHandler
import pytest
from .support import Logger
def pytest_cmdline_main(config):
"""
If we pass --clear-http-cache, we don't enter the main pytest logic, but
use our custom main instead
"""
def mymain(config, session):
print()
print("-" * 20, "SmartRouter HTTP cache", "-" * 20)
# unfortunately pytest-cache doesn't offer a public API to selectively
# clear the cache, so we need to peek its internal. The good news is
# that pytest-cache is very old, stable and robust, so it's likely
# that this won't break anytime soon.
cache = config.cache
base = cache._cachedir.joinpath(cache._CACHE_PREFIX_VALUES, "pyscript")
if not base.exists():
print("No cache found, nothing to do")
return 0
#
print("Requests found in the cache:")
for f in base.rglob("*"):
if f.is_file():
# requests are saved in dirs named pyscript/http:/foo/bar, let's turn
# them into a proper url
url = str(f.relative_to(base))
url = url.replace(":/", "://")
print(" ", url)
shutil.rmtree(base)
print("Cache cleared")
return 0
if config.option.clear_http_cache:
from _pytest.main import wrap_session
return wrap_session(config, mymain)
return None
def pytest_configure(config):
"""
THIS IS A WORKAROUND FOR A pytest QUIRK!
At the moment of writing this conftest defines two new options, --dev and
--no-fake-server, but because of how pytest works, they are available only
if this is the "root conftest" for the test session.
This means that if you are in the pyscript.core directory:
$ py.test # does NOT work
$ py.test tests/integration/ # works
This happens because there is also test py-unit directory, so in the first
case the "root conftest" would be tests/conftest.py (which doesn't exist)
instead of this.
There are various workarounds, but for now we can just detect it and
inform the user.
Related StackOverflow answer: https://stackoverflow.com/a/51733980
"""
if not hasattr(config.option, "dev"):
msg = """
Running a bare "pytest" command from the pyscript.core directory
is not supported. Please use one of the following commands:
- pytest tests/integration
- pytest tests/*
- cd tests/integration; pytest
"""
pytest.fail(msg)
else:
if config.option.dev:
config.option.headed = True
config.option.no_fake_server = True
@pytest.fixture(scope="session")
def logger():
return Logger()
def pytest_addoption(parser):
parser.addoption(
"--no-fake-server",
action="store_true",
help="Use a real HTTP server instead of http://fakeserver",
)
parser.addoption(
"--dev",
action="store_true",
help="Automatically open a devtools panel. Implies --headed and --no-fake-server",
)
parser.addoption(
"--clear-http-cache",
action="store_true",
help="Clear the cache of HTTP requests for SmartRouter",
)
@pytest.fixture(scope="session")
def browser_type_launch_args(request):
"""
Override the browser_type_launch_args defined by pytest-playwright to
support --devtools.
NOTE: this has been tested with pytest-playwright==0.3.0. It might break
with newer versions of it.
"""
# this calls the "original" fixture defined by pytest_playwright.py
launch_options = request.getfixturevalue("browser_type_launch_args")
if request.config.option.dev:
launch_options["devtools"] = True
return launch_options
class DevServer(SuperHTTPServer):
"""
Class for wrapper to run SimpleHTTPServer on Thread.
Ctrl +Only Thread remains dead when terminated with C.
Keyboard Interrupt passes.
"""
def __init__(self, base_url, *args, **kwargs):
self.base_url = base_url
super().__init__(*args, **kwargs)
def run(self):
try:
self.serve_forever()
except KeyboardInterrupt:
pass
finally:
self.server_close()
@pytest.fixture(scope="session")
def dev_server(logger):
class MyHTTPRequestHandler(SimpleHTTPRequestHandler):
enable_cors_headers = True
@classmethod
def my_headers(cls):
if cls.enable_cors_headers:
return {
"Cross-Origin-Embedder-Policy": "require-corp",
"Cross-Origin-Opener-Policy": "same-origin",
}
return {}
def end_headers(self):
self.send_my_headers()
SimpleHTTPRequestHandler.end_headers(self)
def send_my_headers(self):
for k, v in self.my_headers().items():
self.send_header(k, v)
def log_message(self, fmt, *args):
logger.log("http_server", fmt % args, color="blue")
host, port = "localhost", 8080
base_url = f"http://{host}:{port}"
# serve_Run forever under thread
server = DevServer(base_url, (host, port), MyHTTPRequestHandler)
thread = threading.Thread(None, server.run)
thread.start()
yield server # Transition to test here
# End thread
server.shutdown()
thread.join()

File diff suppressed because it is too large Load Diff

View File

@@ -1,476 +0,0 @@
import re
import textwrap
import pytest
from .support import (
PageErrors,
PageErrorsDidNotRaise,
PyScriptTest,
with_execution_thread,
)
@with_execution_thread(None)
class TestSupport(PyScriptTest):
"""
These are NOT tests about PyScript.
They test the PyScriptTest class, i.e. we want to ensure that all the
testing machinery that we have works correctly.
"""
def test_basic(self):
"""
Very basic test, just to check that we can write, serve and read a simple
HTML (no pyscript yet)
"""
doc = """
<html>
<body>
<h1>Hello world</h1>
</body>
</html>
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
content = self.page.content()
assert "<h1>Hello world</h1>" in content
def test_await_with_run_js(self):
self.run_js(
"""
function resolveAfter200MilliSeconds(x) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(x);
}, 200);
});
}
const x = await resolveAfter200MilliSeconds(10);
console.log(x);
"""
)
assert self.console.log.lines[-1] == "10"
def test_console(self):
"""
Test that we capture console.log messages correctly.
"""
doc = """
<html>
<body>
<script>
console.log("my log 1");
console.debug("my debug");
console.info("my info");
console.error("my error");
console.warn("my warning");
console.log("my log 2");
</script>
</body>
</html>
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
assert len(self.console.all.messages) == 6
assert self.console.all.lines == [
"my log 1",
"my debug",
"my info",
"my error",
"my warning",
"my log 2",
]
# fmt: off
assert self.console.all.text == textwrap.dedent("""
my log 1
my debug
my info
my error
my warning
my log 2
""").strip()
# fmt: on
assert self.console.log.lines == ["my log 1", "my log 2"]
assert self.console.debug.lines == ["my debug"]
def test_check_js_errors_simple(self):
doc = """
<html>
<body>
<script>throw new Error('this is an error');</script>
</body>
</html>
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
with pytest.raises(PageErrors) as exc:
self.check_js_errors()
# check that the exception message contains the error message and the
# stack trace
msg = str(exc.value)
expected = textwrap.dedent(
f"""
JS errors found: 1
Error: this is an error
at {self.http_server_addr}/mytest.html:.*
"""
).strip()
assert re.search(expected, msg)
#
# after a call to check_js_errors, the errors are cleared
self.check_js_errors()
#
# JS exceptions are also available in self.console.js_error
assert self.console.js_error.lines[0].startswith("Error: this is an error")
def test_check_js_errors_expected(self):
doc = """
<html>
<body>
<script>throw new Error('this is an error');</script>
</body>
</html>
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
self.check_js_errors("this is an error")
def test_check_js_errors_expected_but_didnt_raise(self):
doc = """
<html>
<body>
<script>throw new Error('this is an error 2');</script>
<script>throw new Error('this is an error 4');</script>
</body>
</html>
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
with pytest.raises(PageErrorsDidNotRaise) as exc:
self.check_js_errors(
"this is an error 1",
"this is an error 2",
"this is an error 3",
"this is an error 4",
)
#
msg = str(exc.value)
expected = textwrap.dedent(
"""
The following JS errors were expected but could not be found:
- this is an error 1
- this is an error 3
"""
).strip()
assert re.search(expected, msg)
def test_check_js_errors_multiple(self):
doc = """
<html>
<body>
<script>throw new Error('error 1');</script>
<script>throw new Error('error 2');</script>
</body>
</html>
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
with pytest.raises(PageErrors) as exc:
self.check_js_errors()
#
msg = str(exc.value)
expected = textwrap.dedent(
"""
JS errors found: 2
Error: error 1
at https://fake_server/mytest.html:.*
Error: error 2
at https://fake_server/mytest.html:.*
"""
).strip()
assert re.search(expected, msg)
#
# check that errors are cleared
self.check_js_errors()
def test_check_js_errors_some_expected_but_others_not(self):
doc = """
<html>
<body>
<script>throw new Error('expected 1');</script>
<script>throw new Error('NOT expected 2');</script>
<script>throw new Error('expected 3');</script>
<script>throw new Error('NOT expected 4');</script>
</body>
</html>
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
with pytest.raises(PageErrors) as exc:
self.check_js_errors("expected 1", "expected 3")
#
msg = str(exc.value)
expected = textwrap.dedent(
"""
JS errors found: 2
Error: NOT expected 2
at https://fake_server/mytest.html:.*
Error: NOT expected 4
at https://fake_server/mytest.html:.*
"""
).strip()
assert re.search(expected, msg)
def test_check_js_errors_expected_not_found_but_other_errors(self):
doc = """
<html>
<body>
<script>throw new Error('error 1');</script>
<script>throw new Error('error 2');</script>
</body>
</html>
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
with pytest.raises(PageErrorsDidNotRaise) as exc:
self.check_js_errors("this is not going to be found")
#
msg = str(exc.value)
expected = textwrap.dedent(
"""
The following JS errors were expected but could not be found:
- this is not going to be found
---
The following JS errors were raised but not expected:
Error: error 1
at https://fake_server/mytest.html:.*
Error: error 2
at https://fake_server/mytest.html:.*
"""
).strip()
assert re.search(expected, msg)
def test_clear_js_errors(self):
doc = """
<html>
<body>
<script>throw new Error('this is an error');</script>
</body>
</html>
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
self.clear_js_errors()
# self.check_js_errors does not raise, because the errors have been
# cleared
self.check_js_errors()
def test_wait_for_console_simple(self):
"""
Test that self.wait_for_console actually waits.
If it's buggy, the test will try to read self.console.log BEFORE the
log has been written and it will fail.
"""
doc = """
<html>
<body>
<script>
setTimeout(function() {
console.log('Page loaded!');
}, 100);
</script>
</body>
</html>
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
# we use a timeout of 200ms to give plenty of time to the page to
# actually run the setTimeout callback
self.wait_for_console("Page loaded!", timeout=200)
assert self.console.log.lines[-1] == "Page loaded!"
def test_wait_for_console_timeout(self):
doc = """
<html>
<body>
</body>
</html>
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
with pytest.raises(TimeoutError):
self.wait_for_console("This text will never be printed", timeout=200)
def test_wait_for_console_dont_wait_if_already_emitted(self):
"""
If the text is already on the console, wait_for_console() should return
immediately without waiting.
"""
doc = """
<html>
<body>
<script>
console.log('Hello world')
console.log('Page loaded!');
</script>
</body>
</html>
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
self.wait_for_console("Page loaded!", timeout=200)
assert self.console.log.lines[-2] == "Hello world"
assert self.console.log.lines[-1] == "Page loaded!"
# the following call should return immediately without waiting
self.wait_for_console("Hello world", timeout=1)
def test_wait_for_console_exception_1(self):
"""
Test that if a JS exception is raised while waiting for the console, we
report the exception and not the timeout.
There are two main cases:
1. there is an exception and the console message does not appear
2. there is an exception but the console message appears anyway
This test checks for case 1. Case 2 is tested by
test_wait_for_console_exception_2
"""
# case 1: there is an exception and the console message does not appear
doc = """
<html>
<body>
<script>throw new Error('this is an error');</script>
</body>
</html>
"""
self.writefile("mytest.html", doc)
# "Page loaded!" will never appear, of course.
self.goto("mytest.html")
with pytest.raises(PageErrors) as exc:
self.wait_for_console("Page loaded!", timeout=200)
assert "this is an error" in str(exc.value)
assert isinstance(exc.value.__context__, TimeoutError)
#
# if we use check_js_errors=False, the error are ignored, but we get the
# Timeout anyway
self.goto("mytest.html")
with pytest.raises(TimeoutError):
self.wait_for_console("Page loaded!", timeout=200, check_js_errors=False)
# we still got a PageErrors, so we need to manually clear it, else the
# test fails at teardown
self.clear_js_errors()
def test_wait_for_console_exception_2(self):
"""
See the description in test_wait_for_console_exception_1.
"""
# case 2: there is an exception, but the console message appears
doc = """
<html>
<body>
<script>
setTimeout(function() {
console.log('Page loaded!');
}, 100);
throw new Error('this is an error');
</script>
</body>
</html>
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
with pytest.raises(PageErrors) as exc:
self.wait_for_console("Page loaded!", timeout=200)
assert "this is an error" in str(exc.value)
#
# with check_js_errors=False, the Error is ignored and the
# wait_for_console succeeds
self.goto("mytest.html")
self.wait_for_console("Page loaded!", timeout=200, check_js_errors=False)
# clear the errors, else the test fails at teardown
self.clear_js_errors()
def test_wait_for_console_match_substring(self):
doc = """
<html>
<body>
<script>
console.log('Foo Bar Baz');
</script>
</body>
</html>
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
with pytest.raises(TimeoutError):
self.wait_for_console("Bar", timeout=200)
#
self.wait_for_console("Bar", timeout=200, match_substring=True)
assert self.console.log.lines[-1] == "Foo Bar Baz"
def test_iter_locator(self):
doc = """
<html>
<body>
<div>foo</div>
<div>bar</div>
<div>baz</div>
</body>
</html>
"""
self.writefile("mytest.html", doc)
self.goto("mytest.html")
divs = self.page.locator("div")
assert divs.count() == 3
texts = [el.inner_text() for el in self.iter_locator(divs)]
assert texts == ["foo", "bar", "baz"]
def test_smartrouter_cache(self):
if self.router is None:
pytest.skip("Cannot test SmartRouter with --dev")
# this is not an image but who cares, I just want the browser to make
# an HTTP request
URL = "https://raw.githubusercontent.com/pyscript/pyscript/main/README.md"
doc = f"""
<html>
<body>
<img src="{URL}">
</body>
</html>
"""
self.writefile("mytest.html", doc)
#
self.router.clear_cache(URL)
self.goto("mytest.html")
assert self.router.requests == [
(200, "fake_server", "https://fake_server/mytest.html"),
(200, "NETWORK", URL),
]
#
# let's visit the page again, now it should be cached
self.goto("mytest.html")
assert self.router.requests == [
# 1st visit
(200, "fake_server", "https://fake_server/mytest.html"),
(200, "NETWORK", URL),
# 2nd visit
(200, "fake_server", "https://fake_server/mytest.html"),
(200, "CACHED", URL),
]
def test_404(self):
"""
Test that we capture a 404 in loading a page that does not exist.
"""
self.goto("this_url_does_not_exist.html")
assert [
"Failed to load resource: the server responded with a status of 404 (Not Found)"
] == self.console.all.lines

View File

@@ -1,404 +0,0 @@
import re
import pytest
from .support import PyScriptTest, only_main, skip_worker
class TestBasic(PyScriptTest):
def test_pyscript_exports(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import RUNNING_IN_WORKER, PyWorker, window, document, sync, current_target
</script>
"""
)
assert self.console.error.lines == []
def test_script_py_hello(self):
self.pyscript_run(
"""
<script type="py">
import js
js.console.log('hello from script py')
</script>
"""
)
assert self.console.log.lines == ["hello from script py"]
def test_py_script_hello(self):
self.pyscript_run(
"""
<py-script>
import js
js.console.log('hello from py-script')
</py-script>
"""
)
assert self.console.log.lines == ["hello from py-script"]
def test_execution_thread(self):
self.pyscript_run(
"""
<script type="py">
import pyscript
import js
js.console.log("worker?", pyscript.RUNNING_IN_WORKER)
</script>
""",
)
assert self.execution_thread in ("main", "worker")
in_worker = self.execution_thread == "worker"
in_worker = str(in_worker).lower()
assert self.console.log.lines[-1] == f"worker? {in_worker}"
@skip_worker("NEXT: it should show a nice error on the page")
def test_no_cors_headers(self):
self.disable_cors_headers()
self.pyscript_run(
"""
<script type="py">
import js
js.console.log("hello")
</script>
""",
wait_for_pyscript=False,
)
assert self.headers == {}
if self.execution_thread == "main":
self.wait_for_pyscript()
assert self.console.log.lines == ["hello"]
self.assert_no_banners()
else:
# XXX adapt and fix the test
expected_alert_banner_msg = (
'(PY1000): When execution_thread is "worker", the site must be cross origin '
"isolated, but crossOriginIsolated is false. To be cross origin isolated, "
"the server must use https and also serve with the following headers: "
'{"Cross-Origin-Embedder-Policy":"require-corp",'
'"Cross-Origin-Opener-Policy":"same-origin"}. '
"The problem may be that one or both of these are missing."
)
alert_banner = self.page.wait_for_selector(".py-error")
assert expected_alert_banner_msg in alert_banner.inner_text()
def test_print(self):
self.pyscript_run(
"""
<script type="py">
print('hello pyscript')
</script>
"""
)
assert self.console.log.lines[-1] == "hello pyscript"
@only_main
def test_input_exception(self):
self.pyscript_run(
"""
<script type="py">
input("what's your name?")
</script>
"""
)
self.check_py_errors(
"Exception: input() doesn't work when PyScript runs in the main thread."
)
@skip_worker("NEXT: exceptions should be displayed in the DOM")
def test_python_exception(self):
self.pyscript_run(
"""
<script type="py">
print('hello pyscript')
raise Exception('this is an error')
</script>
"""
)
assert "hello pyscript" in self.console.log.lines
self.check_py_errors("Exception: this is an error")
#
# check that we show the traceback in the page. Note that here we
# display the "raw" python traceback, without the "[pyexec] Python
# exception:" line (which is useful in the console, but not for the
# user)
banner = self.page.locator(".py-error")
tb_lines = banner.inner_text().splitlines()
assert tb_lines[0] == "Traceback (most recent call last):"
assert tb_lines[-1] == "Exception: this is an error"
@skip_worker("NEXT: py-click doesn't work inside workers")
def test_python_exception_in_event_handler(self):
self.pyscript_run(
"""
<button py-click="onclick">Click me</button>
<script type="py">
def onclick(event):
raise Exception("this is an error inside handler")
</script>
"""
)
self.page.locator("button").click()
self.wait_for_console(
"Exception: this is an error inside handler", match_substring=True
)
self.check_py_errors("Exception: this is an error inside handler")
## error in DOM
tb_lines = self.page.locator(".py-error").inner_text().splitlines()
assert tb_lines[0] == "Traceback (most recent call last):"
assert tb_lines[-1] == "Exception: this is an error inside handler"
@only_main
def test_execution_in_order(self):
"""
Check that they script py tags are executed in the same order they are
defined
"""
self.pyscript_run(
"""
<script type="py">import js; js.console.log('one')</script>
<script type="py">js.console.log('two')</script>
<script type="py">js.console.log('three')</script>
<script type="py">js.console.log('four')</script>
"""
)
assert self.console.log.lines[-4:] == [
"one",
"two",
"three",
"four",
]
def test_escaping_of_angle_brackets(self):
"""
Check that script tags escape angle brackets
"""
self.pyscript_run(
"""
<script type="py">
import js
js.console.log("A", 1<2, 1>2)
js.console.log("B <div></div>")
</script>
<py-script>
import js
js.console.log("C", 1<2, 1>2)
js.console.log("D <div></div>")
</py-script>
"""
)
# in workers the order of execution is not guaranteed, better to play
# safe
lines = sorted(self.console.log.lines[-4:])
assert lines == [
"A true false",
"B <div></div>",
"C true false",
"D <div></div>",
]
def test_packages(self):
self.pyscript_run(
"""
<py-config>
packages = ["asciitree"]
</py-config>
<script type="py">
import js
import asciitree
js.console.log('hello', asciitree.__name__)
</script>
"""
)
assert self.console.log.lines[-3:] == [
"Loading asciitree", # printed by pyodide
"Loaded asciitree", # printed by pyodide
"hello asciitree", # printed by us
]
@pytest.mark.skip("NEXT: No banner")
def test_non_existent_package(self):
self.pyscript_run(
"""
<py-config>
packages = ["i-dont-exist"]
</py-config>
<script type="py">
print('hello')
</script>
""",
wait_for_pyscript=False,
)
expected_alert_banner_msg = (
"(PY1001): Unable to install package(s) 'i-dont-exist'. "
"Unable to find package in PyPI. Please make sure you have "
"entered a correct package name."
)
alert_banner = self.page.wait_for_selector(".alert-banner")
assert expected_alert_banner_msg in alert_banner.inner_text()
self.check_py_errors("Can't fetch metadata for 'i-dont-exist'")
@pytest.mark.skip("NEXT: No banner")
def test_no_python_wheel(self):
self.pyscript_run(
"""
<py-config>
packages = ["opsdroid"]
</py-config>
<script type="py">
print('hello')
</script>
""",
wait_for_pyscript=False,
)
expected_alert_banner_msg = (
"(PY1001): Unable to install package(s) 'opsdroid'. "
"Reason: Can't find a pure Python 3 Wheel for package(s) 'opsdroid'"
)
alert_banner = self.page.wait_for_selector(".alert-banner")
assert expected_alert_banner_msg in alert_banner.inner_text()
self.check_py_errors("Can't find a pure Python 3 wheel for 'opsdroid'")
@only_main
def test_dynamically_add_py_script_tag(self):
self.pyscript_run(
"""
<script>
function addPyScriptTag(event) {
let tag = document.createElement('py-script');
tag.innerHTML = "print('hello world')";
document.body.appendChild(tag);
}
addPyScriptTag()
</script>
""",
timeout=20000,
)
self.page.locator("py-script")
assert self.console.log.lines[-1] == "hello world"
def test_py_script_src_attribute(self):
self.writefile("foo.py", "print('hello from foo')")
self.pyscript_run(
"""
<script type="py" src="foo.py"></script>
"""
)
assert self.console.log.lines[-1] == "hello from foo"
@skip_worker("NEXT: banner not shown")
def test_py_script_src_not_found(self):
self.pyscript_run(
"""
<script type="py" src="foo.py"></script>
""",
check_js_errors=False,
)
assert "Failed to load resource" in self.console.error.lines[0]
# TODO: we need to be sure errors make sense from both main and worker worlds
expected_msg = "(PY0404): Fetching from URL foo.py failed with error 404"
assert any((expected_msg in line) for line in self.console.error.lines)
assert self.assert_banner_message(expected_msg)
# TODO: ... and we shouldn't: it's a module and we better don't leak in global
@pytest.mark.skip("NEXT: we don't expose pyscript on window")
def test_js_version(self):
self.pyscript_run(
"""
<script type="py">
</script>
"""
)
self.page.add_script_tag(content="console.log(pyscript.version)")
assert (
re.match(r"\d{4}\.\d{2}\.\d+(\.[a-zA-Z0-9]+)?", self.console.log.lines[-1])
is not None
)
# TODO: ... and we shouldn't: it's a module and we better don't leak in global
@pytest.mark.skip("NEXT: we don't expose pyscript on window")
def test_python_version(self):
self.pyscript_run(
"""
<script type="py">
import js
js.console.log(pyscript.__version__)
js.console.log(str(pyscript.version_info))
</script>
"""
)
assert (
re.match(r"\d{4}\.\d{2}\.\d+(\.[a-zA-Z0-9]+)?", self.console.log.lines[-2])
is not None
)
assert (
re.match(
r"version_info\(year=\d{4}, month=\d{2}, "
r"minor=\d+, releaselevel='([a-zA-Z0-9]+)?'\)",
self.console.log.lines[-1],
)
is not None
)
@pytest.mark.skip("NEXT: works with <py-script> not with <script>")
def test_getPySrc_returns_source_code(self):
self.pyscript_run(
"""
<py-script>print("hello from py-script")</py-script>
<script type="py">print("hello from script py")</script>
"""
)
pyscript_tag = self.page.locator("py-script")
assert pyscript_tag.inner_html() == ""
assert (
pyscript_tag.evaluate("node => node.srcCode")
== 'print("hello from py-script")'
)
script_py_tag = self.page.locator('script[type="py"]')
assert (
script_py_tag.evaluate("node => node.srcCode")
== 'print("hello from script py")'
)
@skip_worker("NEXT: py-click doesn't work inside workers")
def test_py_attribute_without_id(self):
self.pyscript_run(
"""
<button py-click="myfunc">Click me</button>
<script type="py">
def myfunc(event):
print("hello world!")
</script>
"""
)
btn = self.page.wait_for_selector("button")
btn.click()
self.wait_for_console("hello world!")
assert self.console.log.lines[-1] == "hello world!"
assert self.console.error.lines == []
def test_py_all_done_event(self):
self.pyscript_run(
"""
<script>
addEventListener("py:all-done", () => console.log("2"))
</script>
<script type="py">
print("1")
</script>
"""
)
assert self.console.log.lines == ["1", "2"]
assert self.console.error.lines == []

View File

@@ -1,526 +0,0 @@
################################################################################
import base64
import html
import io
import os
import re
import numpy as np
import pytest
from PIL import Image
from .support import (
PageErrors,
PyScriptTest,
filter_inner_text,
filter_page_content,
only_main,
skip_worker,
wait_for_render,
)
DISPLAY_OUTPUT_ID_PATTERN = r'script-py[id^="py-"]'
class TestDisplay(PyScriptTest):
def test_simple_display(self):
self.pyscript_run(
"""
<script type="py">
print('ciao')
from pyscript import display
display("hello world")
</script>
""",
timeout=20000,
)
node_list = self.page.query_selector_all(DISPLAY_OUTPUT_ID_PATTERN)
pattern = r"<div>hello world</div>"
assert node_list[0].inner_html() == pattern
assert len(node_list) == 1
def test_consecutive_display(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
display('hello 1')
</script>
<p>hello 2</p>
<script type="py">
from pyscript import display
display('hello 3')
</script>
"""
)
inner_text = self.page.inner_text("body")
lines = inner_text.splitlines()
lines = [line for line in filter_page_content(lines)] # remove empty lines
assert lines == ["hello 1", "hello 2", "hello 3"]
def test_target_parameter(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
display('hello world', target="mydiv")
</script>
<div id="mydiv"></div>
"""
)
mydiv = self.page.locator("#mydiv")
assert mydiv.inner_text() == "hello world"
def test_target_parameter_with_sharp(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
display('hello world', target="#mydiv")
</script>
<div id="mydiv"></div>
"""
)
mydiv = self.page.locator("#mydiv")
assert mydiv.inner_text() == "hello world"
def test_non_existing_id_target_raises_value_error(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
display('hello world', target="non-existing")
</script>
"""
)
error_msg = (
f"Invalid selector with id=non-existing. Cannot be found in the page."
)
self.check_py_errors(f"ValueError: {error_msg}")
def test_empty_string_target_raises_value_error(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
display('hello world', target="")
</script>
"""
)
self.check_py_errors(f"ValueError: Cannot have an empty target")
def test_non_string_target_values_raise_typerror(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
display("hello False", target=False)
</script>
"""
)
error_msg = f"target must be str or None, not bool"
self.check_py_errors(f"TypeError: {error_msg}")
self.pyscript_run(
"""
<script type="py">
from pyscript import display
display("hello False", target=123)
</script>
"""
)
error_msg = f"target must be str or None, not int"
self.check_py_errors(f"TypeError: {error_msg}")
@skip_worker("NEXT: display(target=...) does not work")
def test_tag_target_attribute(self):
self.pyscript_run(
"""
<script type="py" target="hello">
from pyscript import display
display('hello')
display("goodbye world", target="goodbye")
display('world')
</script>
<div id="hello"></div>
<div id="goodbye"></div>
"""
)
hello = self.page.locator("#hello")
assert hello.inner_text() == "hello\nworld"
goodbye = self.page.locator("#goodbye")
assert goodbye.inner_text() == "goodbye world"
@skip_worker("NEXT: display target does not work properly")
def test_target_script_py(self):
self.pyscript_run(
"""
<div>ONE</div>
<script type="py" id="two">
# just a placeholder
</script>
<div>THREE</div>
<script type="py">
from pyscript import display
display('TWO', target="two")
</script>
"""
)
text = self.page.inner_text("body")
assert text == "ONE\nTWO\nTHREE"
@skip_worker("NEXT: display target does not work properly")
def test_consecutive_display_target(self):
self.pyscript_run(
"""
<script type="py" id="first">
from pyscript import display
display('hello 1')
</script>
<p>hello in between 1 and 2</p>
<script type="py" id="second">
from pyscript import display
display('hello 2', target="second")
</script>
<script type="py" id="third">
from pyscript import display
display('hello 3')
</script>
"""
)
inner_text = self.page.inner_text("body")
lines = inner_text.splitlines()
lines = [line for line in filter_page_content(lines)] # remove empty lines
assert lines == ["hello 1", "hello in between 1 and 2", "hello 2", "hello 3"]
def test_multiple_display_calls_same_tag(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
display('hello')
display('world')
</script>
"""
)
tag = self.page.locator("script-py")
lines = tag.inner_text().splitlines()
assert lines == ["hello", "world"]
@only_main # with workers, two tags are two separate interpreters
def test_implicit_target_from_a_different_tag(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
def say_hello():
display('hello')
</script>
<script type="py">
from pyscript import display
say_hello()
</script>
"""
)
elems = self.page.locator("script-py")
py0 = elems.nth(0)
py1 = elems.nth(1)
assert py0.inner_text() == ""
assert py1.inner_text() == "hello"
@skip_worker("NEXT: py-click doesn't work")
def test_no_explicit_target(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
def display_hello(error):
display('hello world')
</script>
<button id="my-button" py-click="display_hello">Click me</button>
"""
)
self.page.locator("button").click()
text = self.page.locator("script-py").text_content()
assert "hello world" in text
@skip_worker("NEXT: display target does not work properly")
def test_explicit_target_pyscript_tag(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
def display_hello():
display('hello', target='second-pyscript-tag')
</script>
<script type="py" id="second-pyscript-tag">
display_hello()
</script>
"""
)
text = self.page.locator("script-py").nth(1).inner_text()
assert text == "hello"
@skip_worker("NEXT: display target does not work properly")
def test_explicit_target_on_button_tag(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
def display_hello(error):
display('hello', target='my-button')
</script>
<button id="my-button" py-click="display_hello">Click me</button>
"""
)
self.page.locator("text=Click me").click()
text = self.page.locator("id=my-button").inner_text()
assert "hello" in text
def test_append_true(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
display('AAA', append=True)
display('BBB', append=True)
</script>
"""
)
output = self.page.locator("script-py")
assert output.inner_text() == "AAA\nBBB"
def test_append_false(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
display('AAA', append=False)
display('BBB', append=False)
</script>
"""
)
output = self.page.locator("script-py")
assert output.inner_text() == "BBB"
def test_display_multiple_values(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
hello = 'hello'
world = 'world'
display(hello, world)
</script>
"""
)
output = self.page.locator("script-py")
assert output.inner_text() == "hello\nworld"
def test_display_multiple_append_false(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
display('hello', append=False)
display('world', append=False)
</script>
"""
)
output = self.page.locator("script-py")
assert output.inner_text() == "world"
# TODO: this is a display.py issue to fix when append=False is used
# do not use the first element, just clean up and then append
# remove the # display comment once that's done
def test_display_multiple_append_false_with_target(self):
self.pyscript_run(
"""
<div id="circle-div"></div>
<script type="py">
from pyscript import display
class Circle:
r = 0
def _repr_svg_(self):
return (
f'<svg height="{self.r*2}" width="{self.r*2}">'
f'<circle cx="{self.r}" cy="{self.r}" r="{self.r}" fill="red" /></svg>'
)
circle = Circle()
circle.r += 5
# display(circle, target="circle-div", append=False)
circle.r += 5
display(circle, target="circle-div", append=False)
</script>
"""
)
innerhtml = self.page.locator("id=circle-div").inner_html()
assert (
innerhtml
== '<svg height="20" width="20"><circle cx="10" cy="10" r="10" fill="red"></circle></svg>' # noqa: E501
)
assert self.console.error.lines == []
def test_display_list_dict_tuple(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
l = ['A', 1, '!']
d = {'B': 2, 'List': l}
t = ('C', 3, '!')
display(l, d, t)
</script>
"""
)
inner_text = self.page.inner_text("html")
filtered_inner_text = filter_inner_text(inner_text)
print(filtered_inner_text)
assert (
filtered_inner_text
== "['A', 1, '!']\n{'B': 2, 'List': ['A', 1, '!']}\n('C', 3, '!')"
)
def test_display_should_escape(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
display("<p>hello world</p>")
</script>
"""
)
out = self.page.locator("script-py > div")
assert out.inner_html() == html.escape("<p>hello world</p>")
assert out.inner_text() == "<p>hello world</p>"
def test_display_HTML(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display, HTML
display(HTML("<p>hello world</p>"))
</script>
"""
)
out = self.page.locator("script-py > div")
assert out.inner_html() == "<p>hello world</p>"
assert out.inner_text() == "hello world"
@skip_worker("NEXT: matplotlib-pyodide backend does not work")
def test_image_display(self):
self.pyscript_run(
"""
<py-config> packages = ["matplotlib"] </py-config>
<script type="py">
from pyscript import display
import matplotlib.pyplot as plt
xpoints = [3, 6, 9]
ypoints = [1, 2, 3]
plt.plot(xpoints, ypoints)
display(plt)
</script>
""",
timeout=30 * 1000,
)
wait_for_render(self.page, "*", "<img src=['\"]data:image")
test = self.page.wait_for_selector("img")
img_src = test.get_attribute("src").replace(
"data:image/png;charset=utf-8;base64,", ""
)
img_data = np.asarray(Image.open(io.BytesIO(base64.b64decode(img_src))))
with Image.open(
os.path.join(os.path.dirname(__file__), "test_assets", "line_plot.png"),
) as image:
ref_data = np.asarray(image)
deviation = np.mean(np.abs(img_data - ref_data))
assert deviation == 0.0
self.assert_no_banners()
def test_empty_HTML_and_console_output(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
import js
print('print from python')
js.console.log('print from js')
js.console.error('error from js');
</script>
"""
)
inner_html = self.page.content()
assert re.search("", inner_html)
console_text = self.console.all.lines
assert "print from python" in console_text
assert "print from js" in console_text
assert "error from js" in console_text
def test_text_HTML_and_console_output(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
import js
display('this goes to the DOM')
print('print from python')
js.console.log('print from js')
js.console.error('error from js');
</script>
"""
)
inner_text = self.page.inner_text("script-py")
assert inner_text == "this goes to the DOM"
assert self.console.log.lines[-2:] == [
"print from python",
"print from js",
]
print(self.console.error.lines)
assert self.console.error.lines[-1] == "error from js"
def test_console_line_break(self):
self.pyscript_run(
"""
<script type="py">
print('1print\\n2print')
print('1console\\n2console')
</script>
"""
)
console_text = self.console.all.lines
assert console_text.index("1print") == (console_text.index("2print") - 1)
assert console_text.index("1console") == (console_text.index("2console") - 1)
@skip_worker("NEXT: display target does not work properly")
def test_image_renders_correctly(self):
"""
This is just a sanity check to make sure that images are rendered
in a reasonable way.
"""
self.pyscript_run(
"""
<py-config>
packages = ["pillow"]
</py-config>
<div id="img-target" />
<script type="py">
from pyscript import display
from PIL import Image
img = Image.new("RGB", (4, 4), color=(0, 0, 0))
display(img, target='img-target', append=False)
</script>
""",
)
img_src = self.page.locator("img").get_attribute("src")
assert img_src.startswith("data:image/png;charset=utf-8;base64")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -1,205 +0,0 @@
import pytest
from .support import PyScriptTest, filter_inner_text, only_main
class TestAsync(PyScriptTest):
# ensure_future() and create_task() should behave similarly;
# we'll use the same source code to test both
coroutine_script = """
<script type="py">
import js
import asyncio
js.console.log("first")
async def main():
await asyncio.sleep(1)
js.console.log("third")
asyncio.{func}(main())
js.console.log("second")
</script>
"""
def test_asyncio_ensure_future(self):
self.pyscript_run(self.coroutine_script.format(func="ensure_future"))
self.wait_for_console("third")
assert self.console.log.lines[-3:] == ["first", "second", "third"]
def test_asyncio_create_task(self):
self.pyscript_run(self.coroutine_script.format(func="create_task"))
self.wait_for_console("third")
assert self.console.log.lines[-3:] == ["first", "second", "third"]
def test_asyncio_gather(self):
self.pyscript_run(
"""
<script type="py" id="pys">
import asyncio
import js
from pyodide.ffi import to_js
async def coro(delay):
await asyncio.sleep(delay)
return(delay)
async def get_results():
results = await asyncio.gather(*[coro(d) for d in range(3,0,-1)])
js.console.log(str(results)) #Compare to string representation, not Proxy
js.console.log("DONE")
asyncio.ensure_future(get_results())
</script>
"""
)
self.wait_for_console("DONE")
assert self.console.log.lines[-2:] == ["[3, 2, 1]", "DONE"]
@only_main
def test_multiple_async(self):
self.pyscript_run(
"""
<script type="py">
import js
import asyncio
async def a_func():
for i in range(3):
js.console.log('A', i)
await asyncio.sleep(0.1)
asyncio.ensure_future(a_func())
</script>
<script type="py">
import js
import asyncio
async def b_func():
for i in range(3):
js.console.log('B', i)
await asyncio.sleep(0.1)
js.console.log('b func done')
asyncio.ensure_future(b_func())
</script>
"""
)
self.wait_for_console("b func done")
assert self.console.log.lines == [
"A 0",
"B 0",
"A 1",
"B 1",
"A 2",
"B 2",
"b func done",
]
@only_main
def test_multiple_async_multiple_display_targeted(self):
self.pyscript_run(
"""
<script type="py" id="pyA">
from pyscript import display
import js
import asyncio
async def a_func():
for i in range(2):
display(f'A{i}', target='pyA', append=True)
js.console.log("A", i)
await asyncio.sleep(0.1)
asyncio.ensure_future(a_func())
</script>
<script type="py" id="pyB">
from pyscript import display
import js
import asyncio
async def a_func():
for i in range(2):
display(f'B{i}', target='pyB', append=True)
js.console.log("B", i)
await asyncio.sleep(0.1)
js.console.log("B DONE")
asyncio.ensure_future(a_func())
</script>
"""
)
self.wait_for_console("B DONE")
inner_text = self.page.inner_text("html")
assert "A0\nA1\nB0\nB1" in filter_inner_text(inner_text)
def test_async_display_untargeted(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
import asyncio
import js
async def a_func():
display('A')
await asyncio.sleep(1)
js.console.log("DONE")
asyncio.ensure_future(a_func())
</script>
"""
)
self.wait_for_console("DONE")
assert self.page.locator("script-py").inner_text() == "A"
@only_main
def test_sync_and_async_order(self):
"""
The order of execution is defined as follows:
1. first, we execute all the script tags in order
2. then, we start all the tasks which were scheduled with create_task
Note that tasks are started *AFTER* all py-script tags have been
executed. That's why the console.log() inside mytask1 and mytask2 are
executed after e.g. js.console.log("6").
"""
src = """
<script type="py">
import js
js.console.log("1")
</script>
<script type="py">
import asyncio
import js
async def mytask1():
js.console.log("7")
await asyncio.sleep(0)
js.console.log("9")
js.console.log("2")
asyncio.create_task(mytask1())
js.console.log("3")
</script>
<script type="py">
import js
js.console.log("4")
</script>
<script type="py">
import asyncio
import js
async def mytask2():
js.console.log("8")
await asyncio.sleep(0)
js.console.log("10")
js.console.log("DONE")
js.console.log("5")
asyncio.create_task(mytask2())
js.console.log("6")
</script>
"""
self.pyscript_run(src, wait_for_pyscript=False)
self.wait_for_console("DONE")
lines = self.console.log.lines[-11:]
assert lines == ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "DONE"]

View File

@@ -1,66 +0,0 @@
import pytest
from .support import PyScriptTest
@pytest.mark.xfail(reason="See PR #938")
class TestImportmap(PyScriptTest):
def test_importmap(self):
src = """
export function say_hello(who) {
console.log("hello from", who);
}
"""
self.writefile("mymod.js", src)
#
self.pyscript_run(
"""
<script type="importmap">
{
"imports": {
"mymod": "/mymod.js"
}
}
</script>
<script type="module">
import { say_hello } from "mymod";
say_hello("JS");
</script>
<script type="py">
import mymod
mymod.say_hello("Python")
</script>
"""
)
assert self.console.log.lines == [
"hello from JS",
"hello from Python",
]
def test_invalid_json(self):
self.pyscript_run(
"""
<script type="importmap">
this is not valid JSON
</script>
<script type="py">
print("hello world")
</script>
""",
wait_for_pyscript=False,
)
# this error is raised by the browser itself, when *it* tries to parse
# the import map
self.check_js_errors("Failed to parse import map")
self.wait_for_pyscript()
assert self.console.log.lines == [
"hello world",
]
# this warning is shown by pyscript, when *we* try to parse the import
# map
banner = self.page.locator(".py-warning")
assert "Failed to parse import map" in banner.inner_text()

View File

@@ -1,30 +0,0 @@
from .support import PyScriptTest, with_execution_thread
@with_execution_thread(None)
class TestSmokeTests(PyScriptTest):
"""
Each example requires the same three tests:
- Test that the initial markup loads properly (currently done by
testing the <title> tag's content)
- Testing that pyscript is loading properly
- Testing that the page contains appropriate content after rendering
"""
def test_pydom(self):
# Test the full pydom test suite by running it in the browser
self.goto("test/pyscript_dom/index.html?-v&-s")
assert self.page.title() == "PyDom Test Suite"
# wait for the test suite to finish
self.wait_for_console(
"============================= test session starts =============================="
)
self.assert_no_banners()
results = self.page.inner_html("#tests-terminal")
assert results
assert "PASSED" in results
assert "FAILED" not in results

View File

@@ -1,98 +0,0 @@
import pytest
from .support import PyScriptTest
pytest.skip(
reason="NEXT: pyscript API changed doesn't expose pyscript to window anymore",
allow_module_level=True,
)
class TestInterpreterAccess(PyScriptTest):
"""Test accessing Python objects from JS via pyscript.interpreter"""
def test_interpreter_python_access(self):
self.pyscript_run(
"""
<script type="py">
x = 1
def py_func():
return 2
</script>
"""
)
self.run_js(
"""
const x = await pyscript.interpreter.globals.get('x');
const py_func = await pyscript.interpreter.globals.get('py_func');
const py_func_res = await py_func();
console.log(`x is ${x}`);
console.log(`py_func() returns ${py_func_res}`);
"""
)
assert self.console.log.lines[-2:] == [
"x is 1",
"py_func() returns 2",
]
def test_interpreter_script_execution(self):
"""Test running Python code from js via pyscript.interpreter"""
self.pyscript_run("")
self.run_js(
"""
const interface = pyscript.interpreter._remote.interface;
await interface.runPython('print("Interpreter Ran This")');
"""
)
expected_message = "Interpreter Ran This"
assert self.console.log.lines[-1] == expected_message
py_terminal = self.page.wait_for_selector("py-terminal")
assert py_terminal.text_content() == expected_message
def test_backward_compatibility_runtime_script_execution(self):
"""Test running Python code from js via pyscript.runtime"""
self.pyscript_run("")
self.run_js(
"""
const interface = pyscript.runtime._remote.interpreter;
await interface.runPython('print("Interpreter Ran This")');
"""
)
expected_message = "Interpreter Ran This"
assert self.console.log.lines[-1] == expected_message
py_terminal = self.page.wait_for_selector("py-terminal")
assert py_terminal.text_content() == expected_message
def test_backward_compatibility_runtime_python_access(self):
"""Test accessing Python objects from JS via pyscript.runtime"""
self.pyscript_run(
"""
<script type="py">
x = 1
def py_func():
return 2
</script>
"""
)
self.run_js(
"""
const x = await pyscript.interpreter.globals.get('x');
const py_func = await pyscript.interpreter.globals.get('py_func');
const py_func_res = await py_func();
console.log(`x is ${x}`);
console.log(`py_func() returns ${py_func_res}`);
"""
)
assert self.console.log.lines[-2:] == [
"x is 1",
"py_func() returns 2",
]

View File

@@ -1,419 +0,0 @@
import pytest
from .support import PyScriptTest, skip_worker
pytest.skip(
reason="NEXT: plugins not supported",
allow_module_level=True,
)
# Source code of a simple plugin that creates a Custom Element for testing purposes
CE_PLUGIN_CODE = """
from pyscript import Plugin
from js import console
plugin = Plugin('py-upper')
console.log("py_upper Plugin loaded")
@plugin.register_custom_element('py-up')
class Upper:
def __init__(self, element):
self.element = element
def connect(self):
console.log("Upper plugin connected")
return self.element.originalInnerHTML.upper()
"""
# Source of a plugin hooks into the PyScript App lifecycle events
HOOKS_PLUGIN_CODE = """
from pyscript import Plugin
from js import console
class TestLogger(Plugin):
def configure(self, config):
console.log('configure called')
def beforeLaunch(self, config):
console.log('beforeLaunch called')
def afterSetup(self, config):
console.log('afterSetup called')
def afterStartup(self, config):
console.log('afterStartup called')
def beforePyScriptExec(self, interpreter, src, pyScriptTag):
console.log(f'beforePyScriptExec called')
console.log(f'before_src:{src}')
def afterPyScriptExec(self, interpreter, src, pyScriptTag, result):
console.log(f'afterPyScriptExec called')
console.log(f'after_src:{src}')
def onUserError(self, config):
console.log('onUserError called')
plugin = TestLogger()
"""
# Source of script that defines a plugin with only beforePyScriptExec and
# afterPyScriptExec methods
PYSCRIPT_HOOKS_PLUGIN_CODE = """
from pyscript import Plugin
from js import console
class ExecTestLogger(Plugin):
async def beforePyScriptExec(self, interpreter, src, pyScriptTag):
console.log(f'beforePyScriptExec called')
console.log(f'before_src:{src}')
async def afterPyScriptExec(self, interpreter, src, pyScriptTag, result):
console.log(f'afterPyScriptExec called')
console.log(f'after_src:{src}')
console.log(f'result:{result}')
plugin = ExecTestLogger()
"""
# Source of script that defines a plugin with only beforePyScriptExec and
# afterPyScriptExec methods
PYREPL_HOOKS_PLUGIN_CODE = """
from pyscript import Plugin
from js import console
console.warn("This is in pyrepl hooks file")
class PyReplTestLogger(Plugin):
def beforePyReplExec(self, interpreter, src, outEl, pyReplTag):
console.log(f'beforePyReplExec called')
console.log(f'before_src:{src}')
def afterPyReplExec(self, interpreter, src, outEl, pyReplTag, result):
console.log(f'afterPyReplExec called')
console.log(f'after_src:{src}')
console.log(f'result:{result}')
plugin = PyReplTestLogger()
"""
# Source of a script that doesn't call define a `plugin` attribute
NO_PLUGIN_CODE = """
from pyscript import Plugin
from js import console
class TestLogger(Plugin):
pass
"""
# Source code of a simple plugin that creates a Custom Element for testing purposes
CODE_CE_PLUGIN_BAD_RETURNS = """
from pyscript import Plugin
from js import console
plugin = Plugin('py-broken')
@plugin.register_custom_element('py-up')
class Upper:
def __init__(self, element):
self.element = element
def connect(self):
# Just returning something... anything other than a string should be ignore
return Plugin
"""
HTML_TEMPLATE_WITH_TAG = """
<py-config>
plugins = [
"./{plugin_name}.py"
]
</py-config>
<{tagname}>
{html}
</{tagname}>
"""
HTML_TEMPLATE_NO_TAG = """
<py-config>
plugins = [
"./{plugin_name}.py"
]
</py-config>
"""
def prepare_test(
plugin_name, code, tagname="", html="", template=HTML_TEMPLATE_WITH_TAG
):
"""
Prepares the test by writing a new plugin file named `plugin_name`.py, with `code` as its
content and run `pyscript_run` on `template` formatted with the above inputs to create the
page HTML code.
For example:
>> @prepare_test('py-upper', CE_PLUGIN_CODE, tagname='py-up', html="Hello World")
>> def my_foo(...):
>> ...
will:
* write a new `py-upper.py` file to the FS
* the contents of `py-upper.py` is equal to CE_PLUGIN_CODE
* call self.pyscript_run with the following string:
'''
<py-config>
plugins = [
"./py-upper.py"
]
</py-config>
<py-up>
{html}
</py-up>
'''
* call `my_foo` just like a normal decorator would
"""
def dec(f):
def _inner(self, *args, **kws):
self.writefile(f"{plugin_name}.py", code)
page_html = template.format(
plugin_name=plugin_name, tagname=tagname, html=html
)
self.pyscript_run(page_html)
return f(self, *args, **kws)
return _inner
return dec
class TestPlugin(PyScriptTest):
@skip_worker("FIXME: relative paths")
@prepare_test("py-upper", CE_PLUGIN_CODE, tagname="py-up", html="Hello World")
def test_py_plugin_inline(self):
"""Test that a regular plugin that returns new HTML content from connected works"""
# GIVEN a plugin that returns the all caps version of the tag innerHTML and logs text
# during it's execution/hooks
# EXPECT the plugin logs to be present in the console logs
log_lines = self.console.log.lines
for log_line in ["py_upper Plugin loaded", "Upper plugin connected"]:
assert log_line in log_lines
# EXPECT the inner text of the Plugin CustomElement to be all caps
rendered_text = self.page.locator("py-up").inner_text()
assert rendered_text == "HELLO WORLD"
@skip_worker("FIXME: relative paths")
@prepare_test("hooks_logger", HOOKS_PLUGIN_CODE, template=HTML_TEMPLATE_NO_TAG)
def test_execution_hooks(self):
"""Test that a Plugin that hooks into the PyScript App events, gets called
for each one of them"""
# GIVEN a plugin that logs specific strings for each app execution event
hooks_available = ["afterSetup", "afterStartup"]
hooks_unavailable = [
"configure",
"beforeLaunch",
"beforePyScriptExec",
"afterPyScriptExec",
"beforePyReplExec",
"afterPyReplExec",
]
# EXPECT it to log the correct logs for the events it intercepts
log_lines = self.console.log.lines
num_calls = {
method: log_lines.count(f"{method} called") for method in hooks_available
}
expected_calls = {method: 1 for method in hooks_available}
assert num_calls == expected_calls
# EXPECT it to NOT be called (hence not log anything) the events that happen
# before it's ready, hence is not called
unavailable_called = {
method: f"{method} called" in log_lines for method in hooks_unavailable
}
assert unavailable_called == {method: False for method in hooks_unavailable}
# TODO: It'd be actually better to check that the events get called in order
@skip_worker("FIXME: relative paths")
@prepare_test(
"exec_test_logger",
PYSCRIPT_HOOKS_PLUGIN_CODE,
template=HTML_TEMPLATE_NO_TAG + "\n<script type='py' id='pyid'>x=2; x</script>",
)
def test_pyscript_exec_hooks(self):
"""Test that the beforePyScriptExec and afterPyScriptExec hooks work as intended"""
assert self.page.locator("script") is not None
log_lines: list[str] = self.console.log.lines
assert "beforePyScriptExec called" in log_lines
assert "afterPyScriptExec called" in log_lines
# These could be made better with a utility function that found log lines
# that match a filter function, or start with something
assert "before_src:x=2; x" in log_lines
assert "after_src:x=2; x" in log_lines
assert "result:2" in log_lines
@skip_worker("FIXME: relative paths")
@prepare_test(
"pyrepl_test_logger",
PYREPL_HOOKS_PLUGIN_CODE,
template=HTML_TEMPLATE_NO_TAG + "\n<py-repl id='pyid'>x=2; x</py-repl>",
)
def test_pyrepl_exec_hooks(self):
py_repl = self.page.locator("py-repl")
py_repl.locator("button").click()
# allow afterPyReplExec to also finish before the test finishes
self.wait_for_console("result:2")
log_lines: list[str] = self.console.log.lines
assert "beforePyReplExec called" in log_lines
assert "afterPyReplExec called" in log_lines
# These could be made better with a utility function that found log lines
# that match a filter function, or start with something
assert "before_src:x=2; x" in log_lines
assert "after_src:x=2; x" in log_lines
assert "result:2" in log_lines
@skip_worker("FIXME: relative paths")
@prepare_test("no_plugin", NO_PLUGIN_CODE)
def test_no_plugin_attribute_error(self):
"""
Test a plugin that do not add the `plugin` attribute to its module
"""
# GIVEN a Plugin NO `plugin` attribute in it's module
error_msg = (
"[pyscript/main] Cannot find plugin on Python module no_plugin! Python plugins "
'modules must contain a "plugin" attribute. For more information check the '
"plugins documentation."
)
# EXPECT an error for the missing attribute
assert error_msg in self.console.error.lines
@skip_worker("FIXME: relative paths")
def test_fetch_python_plugin(self):
"""
Test that we can fetch a plugin from a remote URL. Note we need to use
the 'raw' URL for the plugin, otherwise the request will be rejected
by cors policy.
"""
self.pyscript_run(
"""
<py-config>
plugins = [
"https://raw.githubusercontent.com/FabioRosado/pyscript-plugins/main/python/hello-world.py"
]
</py-config>
<py-hello-world></py-hello-world>
"""
)
hello_element = self.page.locator("py-hello-world")
assert hello_element.inner_html() == '<div id="hello">Hello World!</div>'
def test_fetch_js_plugin(self):
self.pyscript_run(
"""
<py-config>
plugins = [
"https://raw.githubusercontent.com/FabioRosado/pyscript-plugins/main/js/hello-world.js"
]
</py-config>
"""
)
hello_element = self.page.locator("py-hello-world")
assert hello_element.inner_html() == "<h1>Hello, world!</h1>"
def test_fetch_js_plugin_bare(self):
self.pyscript_run(
"""
<py-config>
plugins = [
"https://raw.githubusercontent.com/FabioRosado/pyscript-plugins/main/js/hello-world-base.js"
]
</py-config>
"""
)
hello_element = self.page.locator("py-hello-world")
assert hello_element.inner_html() == "<h1>Hello, world!</h1>"
def test_fetch_plugin_no_file_extension(self):
self.pyscript_run(
"""
<py-config>
plugins = [
"https://non-existent.blah/hello-world"
]
</py-config>
""",
wait_for_pyscript=False,
)
expected_msg = (
"(PY2000): Unable to load plugin from "
"'https://non-existent.blah/hello-world'. Plugins "
"need to contain a file extension and be either a "
"python or javascript file."
)
assert self.assert_banner_message(expected_msg)
def test_fetch_js_plugin_non_existent(self):
self.pyscript_run(
"""
<py-config>
plugins = [
"http://non-existent.example.com/hello-world.js"
]
</py-config>
""",
wait_for_pyscript=False,
)
expected_msg = (
"(PY0001): Fetching from URL "
"http://non-existent.example.com/hello-world.js failed "
"with error 'Failed to fetch'. Are your filename and "
"path correct?"
)
assert self.assert_banner_message(expected_msg)
def test_fetch_js_no_export(self):
self.pyscript_run(
"""
<py-config>
plugins = [
"https://raw.githubusercontent.com/FabioRosado/pyscript-plugins/main/js/hello-world-no-export.js"
]
</py-config>
""",
wait_for_pyscript=False,
)
expected_message = (
"(PY2001): Unable to load plugin from "
"'https://raw.githubusercontent.com/FabioRosado/pyscript-plugins"
"/main/js/hello-world-no-export.js'. "
"Plugins need to contain a default export."
)
assert self.assert_banner_message(expected_message)

View File

@@ -1,215 +0,0 @@
import os
import pytest
from .support import PyScriptTest, with_execution_thread
# Disable the main/worker dual testing, for two reasons:
#
# 1. the <py-config> logic happens before we start the worker, so there is
# no point in running these tests twice
#
# 2. the logic to inject execution_thread into <py-config> works only with
# plain <py-config> tags, but here we want to test all weird combinations
# of config
@with_execution_thread(None)
class TestConfig(PyScriptTest):
def test_py_config_inline_pyscript(self):
self.pyscript_run(
"""
<py-config>
name = "foobar"
</py-config>
<py-script async>
from pyscript import window
window.console.log("config name:", window.pyConfig.name)
</py-script>
"""
)
assert self.console.log.lines[-1] == "config name: foobar"
@pytest.mark.skip("NEXT: works with <py-script> not with <script>")
def test_py_config_inline_scriptpy(self):
self.pyscript_run(
"""
<py-config>
name = "foobar"
</py-config>
<script type="py" async>
from pyscript import window
window.console.log("config name:", window.pyConfig.name)
</script>
"""
)
assert self.console.log.lines[-1] == "config name: foobar"
@pytest.mark.skip("NEXT: works with <py-script> not with <script>")
def test_py_config_external(self):
pyconfig_toml = """
name = "app with external config"
"""
self.writefile("pyconfig.toml", pyconfig_toml)
self.pyscript_run(
"""
<py-config src="pyconfig.toml"></py-config>
<script type="py" async>
from pyscript import window
window.console.log("config name:", window.pyConfig.name)
</script>
"""
)
assert self.console.log.lines[-1] == "config name: app with external config"
def test_invalid_json_config(self):
# we need wait_for_pyscript=False because we bail out very soon,
# before being able to write 'PyScript page fully initialized'
self.pyscript_run(
"""
<py-config type="json">
[[
</py-config>
""",
wait_for_pyscript=False,
)
banner = self.page.wait_for_selector(".py-error")
# assert "Unexpected end of JSON input" in self.console.error.text
expected = "(PY1000): Invalid JSON\n" "Unexpected end of JSON input"
assert banner.inner_text() == expected
def test_invalid_toml_config(self):
# we need wait_for_pyscript=False because we bail out very soon,
# before being able to write 'PyScript page fully initialized'
self.pyscript_run(
"""
<py-config>
[[
</py-config>
""",
wait_for_pyscript=False,
)
banner = self.page.wait_for_selector(".py-error")
# assert "Expected DoubleQuote" in self.console.error.text
expected = (
"(PY1000): Invalid TOML\n"
"Expected DoubleQuote, Whitespace, or [a-z], [A-Z], "
'[0-9], "-", "_" but end of input found.'
)
assert banner.inner_text() == expected
def test_ambiguous_py_config(self):
self.pyscript_run(
"""
<py-config>name = "first"</py-config>
<script type="py" config="second.toml"></script>
""",
wait_for_pyscript=False,
)
banner = self.page.wait_for_selector(".py-error")
expected = "(PY0409): Ambiguous py-config VS config attribute"
assert banner.text_content() == expected
def test_multiple_attributes_py_config(self):
self.pyscript_run(
"""
<script type="py" config="first.toml"></script>
<script type="py" config="second.toml"></script>
""",
wait_for_pyscript=False,
)
banner = self.page.wait_for_selector(".py-error")
expected = "(PY0409): Unable to use different configs on main"
assert banner.text_content() == expected
def test_multiple_py_config(self):
self.pyscript_run(
"""
<py-config>
name = "foobar"
</py-config>
<py-config>
name = "this is ignored"
</py-config>
<script type="py">
import js
#config = js.pyscript_get_config()
#js.console.log("config name:", config.name)
</script>
""",
wait_for_pyscript=False,
)
banner = self.page.wait_for_selector(".py-error")
expected = "(PY0409): Too many py-config"
assert banner.text_content() == expected
def test_paths(self):
self.writefile("a.py", "x = 'hello from A'")
self.writefile("b.py", "x = 'hello from B'")
self.pyscript_run(
"""
<py-config>
[[fetch]]
files = ["./a.py", "./b.py"]
</py-config>
<script type="py">
import js
import a, b
js.console.log(a.x)
js.console.log(b.x)
</script>
"""
)
assert self.console.log.lines[-2:] == [
"hello from A",
"hello from B",
]
@pytest.mark.skip("NEXT: emit an error if fetch fails")
def test_paths_that_do_not_exist(self):
self.pyscript_run(
"""
<py-config>
[[fetch]]
files = ["./f.py"]
</py-config>
<script type="py">
print("this should not be printed")
</script>
""",
wait_for_pyscript=False,
)
expected = "(PY0404): Fetching from URL ./f.py failed with " "error 404"
inner_html = self.page.locator(".py-error").inner_html()
assert expected in inner_html
assert expected in self.console.error.lines[-1]
assert self.console.log.lines == []
def test_paths_from_packages(self):
self.writefile("utils/__init__.py", "")
self.writefile("utils/a.py", "x = 'hello from A'")
self.pyscript_run(
"""
<py-config>
[[fetch]]
from = "utils"
to_folder = "pkg"
files = ["__init__.py", "a.py"]
</py-config>
<script type="py">
import js
from pkg.a import x
js.console.log(x)
</script>
"""
)
assert self.console.log.lines[-1] == "hello from A"

View File

@@ -1,663 +0,0 @@
import platform
import pytest
from .support import PyScriptTest, skip_worker
pytest.skip(
reason="NEXT: pyscript NEXT doesn't support the REPL yet",
allow_module_level=True,
)
class TestPyRepl(PyScriptTest):
def _replace(self, py_repl, newcode):
"""
Clear the editor and write new code in it.
WARNING: this assumes that the textbox has already the focus
"""
# clear the editor, write new code
if "macOS" in platform.platform():
self.page.keyboard.press("Meta+A")
else:
self.page.keyboard.press("Control+A")
self.page.keyboard.press("Backspace")
self.page.keyboard.type(newcode)
def test_repl_loads(self):
self.pyscript_run(
"""
<py-repl></py-repl>
"""
)
py_repl = self.page.query_selector("py-repl .py-repl-box")
assert py_repl
def test_execute_preloaded_source(self):
"""
Unfortunately it tests two things at once, but it's impossible to write a
smaller test. I think this is the most basic test that we can write.
We test that:
1. the source code that we put in the tag is loaded inside the editor
2. clicking the button executes it
"""
self.pyscript_run(
"""
<py-repl>
print('hello from py-repl')
</py-repl>
"""
)
py_repl = self.page.locator("py-repl")
src = py_repl.locator("div.cm-content").inner_text()
assert "print('hello from py-repl')" in src
py_repl.locator("button").click()
self.page.wait_for_selector("py-terminal")
assert self.console.log.lines[-1] == "hello from py-repl"
def test_execute_code_typed_by_the_user(self):
self.pyscript_run(
"""
<py-repl></py-repl>
"""
)
py_repl = self.page.locator("py-repl")
py_repl.type('print("hello")')
py_repl.locator("button").click()
self.page.wait_for_selector("py-terminal")
assert self.console.log.lines[-1] == "hello"
def test_execute_on_shift_enter(self):
self.pyscript_run(
"""
<py-repl>
print("hello world")
</py-repl>
"""
)
self.page.wait_for_selector("py-repl .py-repl-run-button")
self.page.keyboard.press("Shift+Enter")
self.page.wait_for_selector("py-terminal")
assert self.console.log.lines[-1] == "hello world"
# Shift-enter should not add a newline to the editor
assert self.page.locator(".cm-line").count() == 1
@skip_worker("FIXME: display()")
def test_display(self):
self.pyscript_run(
"""
<py-repl>
display('hello world')
</py-repl>
"""
)
py_repl = self.page.locator("py-repl")
py_repl.locator("button").click()
out_div = self.page.wait_for_selector("#py-internal-0-repl-output")
assert out_div.inner_text() == "hello world"
@skip_worker("TIMEOUT")
def test_show_last_expression(self):
"""
Test that we display() the value of the last expression, as you would
expect by a REPL
"""
self.pyscript_run(
"""
<py-repl>
42
</py-repl>
"""
)
py_repl = self.page.locator("py-repl")
py_repl.locator("button").click()
out_div = self.page.wait_for_selector("#py-internal-0-repl-output")
assert out_div.inner_text() == "42"
@skip_worker("TIMEOUT")
def test_show_last_expression_with_output(self):
"""
Test that we display() the value of the last expression, as you would
expect by a REPL
"""
self.pyscript_run(
"""
<div id="repl-target"></div>
<py-repl output="repl-target">
42
</py-repl>
"""
)
py_repl = self.page.locator("py-repl")
py_repl.locator("button").click()
out_div = py_repl.locator("div.py-repl-output")
assert out_div.all_inner_texts()[0] == ""
out_div = self.page.wait_for_selector("#repl-target")
assert out_div.inner_text() == "42"
@skip_worker("FIXME: display()")
def test_run_clears_previous_output(self):
"""
Check that we clear the previous output of the cell before executing it
again
"""
self.pyscript_run(
"""
<py-repl>
display('hello world')
</py-repl>
"""
)
py_repl = self.page.locator("py-repl")
self.page.keyboard.press("Shift+Enter")
out_div = self.page.wait_for_selector("#py-internal-0-repl-output")
assert out_div.inner_text() == "hello world"
# clear the editor, write new code, execute
self._replace(py_repl, "display('another output')")
self.page.keyboard.press("Shift+Enter")
# test runner can be too fast, the line below should wait for output to change
out_div = self.page.wait_for_selector("#py-internal-0-repl-output")
assert out_div.inner_text() == "another output"
def test_python_exception(self):
"""
See also test01_basic::test_python_exception, since it's very similar
"""
self.pyscript_run(
"""
<py-repl>
raise Exception('this is an error')
</py-repl>
"""
)
py_repl = self.page.locator("py-repl")
py_repl.locator("button").click()
self.page.wait_for_selector(".py-error")
#
# check that we sent the traceback to the console
tb_lines = self.console.error.lines[-1].splitlines()
assert tb_lines[0] == "[pyexec] Python exception:"
assert tb_lines[1] == "Traceback (most recent call last):"
assert tb_lines[-1] == "Exception: this is an error"
#
# check that we show the traceback in the page
err_pre = py_repl.locator("div.py-repl-output > pre.py-error")
tb_lines = err_pre.inner_text().splitlines()
assert tb_lines[0] == "Traceback (most recent call last):"
assert tb_lines[-1] == "Exception: this is an error"
#
self.check_py_errors("this is an error")
@skip_worker("FIXME: display()")
def test_multiple_repls(self):
"""
Multiple repls showing in the correct order in the page
"""
self.pyscript_run(
"""
<py-repl data-testid=="first"> display("first") </py-repl>
<py-repl data-testid=="second"> display("second") </py-repl>
"""
)
first_py_repl = self.page.get_by_text("first")
first_py_repl.click()
self.page.keyboard.press("Shift+Enter")
self.page.wait_for_selector("#py-internal-0-repl-output")
assert self.page.inner_text("#py-internal-0-repl-output") == "first"
second_py_repl = self.page.get_by_text("second")
second_py_repl.click()
self.page.keyboard.press("Shift+Enter")
self.page.wait_for_selector("#py-internal-1-repl-output")
assert self.page.inner_text("#py-internal-1-repl-output") == "second"
@skip_worker("FIXME: display()")
def test_python_exception_after_previous_output(self):
self.pyscript_run(
"""
<py-repl>
display('hello world')
</py-repl>
"""
)
py_repl = self.page.locator("py-repl")
self.page.keyboard.press("Shift+Enter")
out_div = self.page.wait_for_selector("#py-internal-0-repl-output")
assert out_div.inner_text() == "hello world"
#
# clear the editor, write new code, execute
self._replace(py_repl, "0/0")
self.page.keyboard.press("Shift+Enter")
# test runner can be too fast, the line below should wait for output to change
out_div = self.page.wait_for_selector("#py-internal-0-repl-output")
assert "hello world" not in out_div.inner_text()
assert "ZeroDivisionError" in out_div.inner_text()
#
self.check_py_errors("ZeroDivisionError")
@skip_worker("FIXME: js.document")
def test_hide_previous_error_after_successful_run(self):
"""
this tests the fact that a new error div should be created once there's an
error but also that it should disappear automatically once the error
is fixed
"""
self.pyscript_run(
"""
<py-repl>
raise Exception('this is an error')
</py-repl>
"""
)
py_repl = self.page.locator("py-repl")
self.page.keyboard.press("Shift+Enter")
out_div = self.page.wait_for_selector("#py-internal-0-repl-output")
assert "this is an error" in out_div.inner_text()
#
self._replace(py_repl, "display('hello')")
self.page.keyboard.press("Shift+Enter")
# test runner can be too fast, the line below should wait for output to change
out_div = self.page.wait_for_selector("#py-internal-0-repl-output")
assert out_div.inner_text() == "hello"
#
self.check_py_errors("this is an error")
def test_output_attribute_does_not_exist(self):
"""
If we try to use an attribute which doesn't exist, we display an error
instead
"""
self.pyscript_run(
"""
<py-repl output="I-dont-exist">
print('I will not be executed')
</py-repl>
"""
)
py_repl = self.page.locator("py-repl")
py_repl.locator("button").click()
banner = self.page.wait_for_selector(".py-warning")
banner_content = banner.inner_text()
expected = (
'output = "I-dont-exist" does not match the id of any element on the page.'
)
assert banner_content == expected
@skip_worker("TIMEOUT")
def test_auto_generate(self):
self.pyscript_run(
"""
<py-repl auto-generate="true">
</py-repl>
"""
)
py_repls = self.page.locator("py-repl")
outputs = py_repls.locator("div.py-repl-output")
assert py_repls.count() == 1
assert outputs.count() == 1
#
# evaluate the py-repl, and wait for the newly generated one
self.page.keyboard.type("'hello'")
self.page.keyboard.press("Shift+Enter")
self.page.locator('py-repl[exec-id="1"]').wait_for()
assert py_repls.count() == 2
assert outputs.count() == 2
#
# now we type something else: the new py-repl should have the focus
self.page.keyboard.type("'world'")
self.page.keyboard.press("Shift+Enter")
self.page.locator('py-repl[exec-id="2"]').wait_for()
assert py_repls.count() == 3
assert outputs.count() == 3
#
# check that the code and the outputs are in order
out_texts = [el.inner_text() for el in self.iter_locator(outputs)]
assert out_texts == ["hello", "world", ""]
@skip_worker("FIXME: display()")
def test_multiple_repls_mixed_display_order(self):
"""
Displaying several outputs that don't obey the order in which the original
repl displays were created using the auto_generate attr
"""
self.pyscript_run(
"""
<py-repl auto-generate="true" data-testid=="first"> display("root first") </py-repl>
<py-repl auto-generate="true" data-testid=="second"> display("root second") </py-repl>
"""
)
second_py_repl = self.page.get_by_text("root second")
second_py_repl.click()
self.page.keyboard.press("Shift+Enter")
self.page.wait_for_selector("#py-internal-1-repl-output")
self.page.keyboard.type("display('second children')")
self.page.keyboard.press("Shift+Enter")
self.page.wait_for_selector("#py-internal-1-1-repl-output")
first_py_repl = self.page.get_by_text("root first")
first_py_repl.click()
self.page.keyboard.press("Shift+Enter")
self.page.wait_for_selector("#py-internal-0-repl-output")
self.page.keyboard.type("display('first children')")
self.page.keyboard.press("Shift+Enter")
self.page.wait_for_selector("#py-internal-0-1-repl-output")
assert self.page.inner_text("#py-internal-1-1-repl-output") == "second children"
assert self.page.inner_text("#py-internal-0-1-repl-output") == "first children"
@skip_worker("FIXME: display()")
def test_repl_output_attribute(self):
# Test that output attribute sends stdout to the element
# with the given ID, but not display()
self.pyscript_run(
"""
<div id="repl-target"></div>
<py-repl output="repl-target">
print('print from py-repl')
display('display from py-repl')
</py-repl>
"""
)
py_repl = self.page.locator("py-repl")
py_repl.locator("button").click()
target = self.page.wait_for_selector("#repl-target")
assert "print from py-repl" in target.inner_text()
out_div = self.page.wait_for_selector("#py-internal-0-repl-output")
assert out_div.inner_text() == "display from py-repl"
self.assert_no_banners()
@skip_worker("FIXME: js.document")
def test_repl_output_display_async(self):
# py-repls running async code are not expected to
# send display to element element
self.pyscript_run(
"""
<div id="repl-target"></div>
<script type="py">
import asyncio
import js
async def print_it():
await asyncio.sleep(1)
print('print from py-repl')
async def display_it():
display('display from py-repl')
await asyncio.sleep(2)
async def done():
await asyncio.sleep(3)
js.console.log("DONE")
</script>
<py-repl output="repl-target">
asyncio.ensure_future(print_it());
asyncio.ensure_future(display_it());
asyncio.ensure_future(done());
</py-repl>
"""
)
py_repl = self.page.locator("py-repl")
py_repl.locator("button").click()
self.wait_for_console("DONE")
assert self.page.locator("#repl-target").text_content() == ""
self.assert_no_banners()
@skip_worker("FIXME: js.document")
def test_repl_stdio_dynamic_tags(self):
self.pyscript_run(
"""
<div id="first"></div>
<div id="second"></div>
<py-repl output="first">
import js
print("first.")
# Using string, since no clean way to write to the
# code contents of the CodeMirror in a PyRepl
newTag = '<py-repl id="second-repl" output="second">print("second.")</py-repl>'
js.document.body.innerHTML += newTag
</py-repl>
"""
)
py_repl = self.page.locator("py-repl")
py_repl.locator("button").click()
assert self.page.wait_for_selector("#first").inner_text() == "first.\n"
second_repl = self.page.locator("py-repl#second-repl")
second_repl.locator("button").click()
assert self.page.wait_for_selector("#second").inner_text() == "second.\n"
def test_repl_output_id_errors(self):
self.pyscript_run(
"""
<py-repl output="not-on-page">
print("bad.")
print("bad.")
</py-repl>
<py-repl output="not-on-page">
print("bad.")
</py-repl>
"""
)
py_repls = self.page.query_selector_all("py-repl")
for repl in py_repls:
repl.query_selector_all("button")[0].click()
banner = self.page.wait_for_selector(".py-warning")
banner_content = banner.inner_text()
expected = (
'output = "not-on-page" does not match the id of any element on the page.'
)
assert banner_content == expected
def test_repl_stderr_id_errors(self):
self.pyscript_run(
"""
<py-repl stderr="not-on-page">
import sys
print("bad.", file=sys.stderr)
print("bad.", file=sys.stderr)
</py-repl>
<py-repl stderr="not-on-page">
print("bad.", file=sys.stderr)
</py-repl>
"""
)
py_repls = self.page.query_selector_all("py-repl")
for repl in py_repls:
repl.query_selector_all("button")[0].click()
banner = self.page.wait_for_selector(".py-warning")
banner_content = banner.inner_text()
expected = (
'stderr = "not-on-page" does not match the id of any element on the page.'
)
assert banner_content == expected
def test_repl_output_stderr(self):
# Test that stderr works, and routes to the same location as stdout
# Also, repls with the stderr attribute route to an additional location
self.pyscript_run(
"""
<div id="stdout-div"></div>
<div id="stderr-div"></div>
<py-repl output="stdout-div" stderr="stderr-div">
import sys
print("one.", file=sys.stderr)
print("two.")
</py-repl>
"""
)
py_repl = self.page.locator("py-repl")
py_repl.locator("button").click()
assert self.page.wait_for_selector("#stdout-div").inner_text() == "one.\ntwo.\n"
assert self.page.wait_for_selector("#stderr-div").inner_text() == "one.\n"
self.assert_no_banners()
@skip_worker("TIMEOUT")
def test_repl_output_attribute_change(self):
# If the user changes the 'output' attribute of a <py-repl> tag mid-execution,
# Output should no longer go to the selected div and a warning should appear
self.pyscript_run(
"""
<div id="first"></div>
<div id="second"></div>
<!-- There is no tag with id "third" -->
<py-repl id="repl-tag" output="first">
print("one.")
# Change the 'output' attribute of this tag
import js
this_tag = js.document.getElementById("repl-tag")
this_tag.setAttribute("output", "second")
print("two.")
this_tag.setAttribute("output", "third")
print("three.")
</script>
"""
)
py_repl = self.page.locator("py-repl")
py_repl.locator("button").click()
assert self.page.wait_for_selector("#first").inner_text() == "one.\n"
assert self.page.wait_for_selector("#second").inner_text() == "two.\n"
expected_alert_banner_msg = (
'output = "third" does not match the id of any element on the page.'
)
alert_banner = self.page.wait_for_selector(".alert-banner")
assert expected_alert_banner_msg in alert_banner.inner_text()
@skip_worker("TIMEOUT")
def test_repl_output_element_id_change(self):
# If the user changes the ID of the targeted DOM element mid-execution,
# Output should no longer go to the selected element and a warning should appear
self.pyscript_run(
"""
<div id="first"></div>
<div id="second"></div>
<!-- There is no tag with id "third" -->
<py-repl id="pyscript-tag" output="first">
print("one.")
# Change the ID of the targeted DIV to something else
import js
target_tag = js.document.getElementById("first")
# should fail and show banner
target_tag.setAttribute("id", "second")
print("two.")
# But changing both the 'output' attribute and the id of the target
# should work
target_tag.setAttribute("id", "third")
js.document.getElementById("pyscript-tag").setAttribute("output", "third")
print("three.")
</py-repl>
"""
)
py_repl = self.page.locator("py-repl")
py_repl.locator("button").click()
# Note the ID of the div has changed by the time of this assert
assert self.page.wait_for_selector("#third").inner_text() == "one.\nthree.\n"
expected_alert_banner_msg = (
'output = "first" does not match the id of any element on the page.'
)
alert_banner = self.page.wait_for_selector(".alert-banner")
assert expected_alert_banner_msg in alert_banner.inner_text()
def test_repl_load_content_from_src(self):
self.writefile("loadReplSrc1.py", "print('1')")
self.pyscript_run(
"""
<py-repl id="py-repl1" output="replOutput1" src="./loadReplSrc1.py"></py-repl>
<div id="replOutput1"></div>
"""
)
successMsg = "[py-repl] loading code from ./loadReplSrc1.py to repl...success"
assert self.console.info.lines[-1] == successMsg
py_repl = self.page.locator("py-repl")
code = py_repl.locator("div.cm-content").inner_text()
assert "print('1')" in code
@skip_worker("TIMEOUT")
def test_repl_src_change(self):
self.writefile("loadReplSrc2.py", "2")
self.writefile("loadReplSrc3.py", "print('3')")
self.pyscript_run(
"""
<py-repl id="py-repl2" output="replOutput2" src="./loadReplSrc2.py"></py-repl>
<div id="replOutput2"></div>
<py-repl id="py-repl3" output="replOutput3">
import js
target_tag = js.document.getElementById("py-repl2")
target_tag.setAttribute("src", "./loadReplSrc3.py")
</py-repl>
<div id="replOutput3"></div>
"""
)
successMsg1 = "[py-repl] loading code from ./loadReplSrc2.py to repl...success"
assert self.console.info.lines[-1] == successMsg1
py_repl3 = self.page.locator("py-repl#py-repl3")
py_repl3.locator("button").click()
py_repl2 = self.page.locator("py-repl#py-repl2")
py_repl2.locator("button").click()
self.page.wait_for_selector("py-terminal")
assert self.console.log.lines[-1] == "3"
successMsg2 = "[py-repl] loading code from ./loadReplSrc3.py to repl...success"
assert self.console.info.lines[-1] == successMsg2
def test_repl_src_path_that_do_not_exist(self):
self.pyscript_run(
"""
<py-repl id="py-repl4" output="replOutput4" src="./loadReplSrc4.py"></py-repl>
<div id="replOutput4"></div>
"""
)
errorMsg = (
"(PY0404): Fetching from URL ./loadReplSrc4.py "
"failed with error 404 (Not Found). "
"Are your filename and path correct?"
)
assert self.console.error.lines[-1] == errorMsg

View File

@@ -1,188 +0,0 @@
import time
import pytest
from playwright.sync_api import expect
from .support import PageErrors, PyScriptTest, only_worker, skip_worker
class TestPyTerminal(PyScriptTest):
@skip_worker("We do support multiple worker terminal now")
def test_multiple_terminals(self):
"""
Multiple terminals are not currently supported
"""
self.pyscript_run(
"""
<script type="py" terminal></script>
<script type="py" terminal></script>
""",
wait_for_pyscript=False,
check_js_errors=False,
)
assert self.assert_banner_message("You can use at most 1 main terminal")
with pytest.raises(PageErrors, match="You can use at most 1 main terminal"):
self.check_js_errors()
# TODO: interactive shell still unclear
# @only_worker
# def test_py_terminal_input(self):
# """
# Only worker py-terminal accepts an input
# """
# self.pyscript_run(
# """
# <script type="py" terminal></script>
# """,
# wait_for_pyscript=False,
# )
# self.page.get_by_text(">>> ", exact=True).wait_for()
# self.page.keyboard.type("'the answer is ' + str(6 * 7)")
# self.page.keyboard.press("Enter")
# self.page.get_by_text("the answer is 42").wait_for()
@only_worker
def test_py_terminal_os_write(self):
"""
An `os.write("text")` should land in the terminal
"""
self.pyscript_run(
"""
<script type="py" terminal>
import os
os.write(1, str.encode("hello\\n"))
os.write(2, str.encode("world\\n"))
</script>
""",
wait_for_pyscript=False,
)
self.page.get_by_text("hello\n").wait_for()
self.page.get_by_text("world\n").wait_for()
def test_py_terminal(self):
"""
1. <py-terminal> should redirect stdout and stderr to the DOM
2. they also go to the console as usual
"""
self.pyscript_run(
"""
<script type="py" terminal>
import sys
print('hello world')
print('this goes to stderr', file=sys.stderr)
print('this goes to stdout')
</script>
""",
wait_for_pyscript=False,
)
self.page.get_by_text("hello world").wait_for()
term = self.page.locator("py-terminal")
term_lines = term.inner_text().splitlines()
assert term_lines[0:3] == [
"hello world",
"this goes to stderr",
"this goes to stdout",
]
@skip_worker(
"Workers don't have events + two different workers don't share the same I/O"
)
def test_button_action(self):
self.pyscript_run(
"""
<script type="py">
def greetings(event):
print('hello world')
</script>
<script type="py" terminal></script>
<button id="my-button" py-click="greetings">Click me</button>
"""
)
term = self.page.locator("py-terminal")
self.page.locator("button").click()
last_line = self.page.get_by_text("hello world")
last_line.wait_for()
assert term.inner_text().rstrip() == "hello world"
def test_xterm_function(self):
"""Test a few basic behaviors of the xtermjs terminal.
This test isn't meant to capture all of the behaviors of an xtermjs terminal;
rather, it confirms with a few basic formatting sequences that (1) the xtermjs
terminal is functioning/loaded correctly and (2) that output toward that terminal
isn't being escaped in a way that prevents it reacting to escape sequences. The
main goal is preventing regressions.
"""
self.pyscript_run(
"""
<script type="py" terminal>
print("\x1b[33mYellow\x1b[0m")
print("\x1b[4mUnderline\x1b[24m")
print("\x1b[1mBold\x1b[22m")
print("\x1b[3mItalic\x1b[23m")
print("done")
</script>
""",
wait_for_pyscript=False,
)
# Wait for "done" to actually appear in the xterm; may be delayed,
# since xtermjs processes its input buffer in chunks
last_line = self.page.get_by_text("done")
last_line.wait_for()
# Yes, this is not ideal. However, per http://xtermjs.org/docs/guides/hooks/
# "It is not possible to conclude, whether or when a certain chunk of data
# will finally appear on the screen," which is what we'd really like to know.
# By waiting for the "done" test to appear above, we get close, however it is
# possible for the text to appear and not be 'processed' (i.e.) formatted. This
# small delay should avoid that.
time.sleep(1)
rows = self.page.locator(".xterm-rows")
# The following use locator.evaluate() and getComputedStyle to get
# the computed CSS values; this tests that the lines are rendering
# properly in a better way than just testing whether they
# get the right css classes from xtermjs
# First line should be yellow
first_line = rows.locator("div").nth(0)
first_char = first_line.locator("span").nth(0)
color = first_char.evaluate(
"(element) => getComputedStyle(element).getPropertyValue('color')"
)
assert color == "rgb(196, 160, 0)"
# Second line should be underlined
second_line = rows.locator("div").nth(1)
first_char = second_line.locator("span").nth(0)
text_decoration = first_char.evaluate(
"(element) => getComputedStyle(element).getPropertyValue('text-decoration')"
)
assert "underline" in text_decoration
# We'll make sure the 'bold' font weight is more than the
# default font weight without specifying a specific value
baseline_font_weight = first_char.evaluate(
"(element) => getComputedStyle(element).getPropertyValue('font-weight')"
)
# Third line should be bold
third_line = rows.locator("div").nth(2)
first_char = third_line.locator("span").nth(0)
font_weight = first_char.evaluate(
"(element) => getComputedStyle(element).getPropertyValue('font-weight')"
)
assert int(font_weight) > int(baseline_font_weight)
# Fourth line should be italic
fourth_line = rows.locator("div").nth(3)
first_char = fourth_line.locator("span").nth(0)
font_style = first_char.evaluate(
"(element) => getComputedStyle(element).getPropertyValue('font-style')"
)
assert font_style == "italic"

View File

@@ -1,124 +0,0 @@
import pytest
from .support import PyScriptTest, with_execution_thread
# these tests don't need to run in 'main' and 'worker' modes: the workers are
# already tested explicitly by some of them (see e.g.
# test_script_type_py_worker_attribute)
@with_execution_thread(None)
class TestScriptTypePyScript(PyScriptTest):
def test_display_line_break(self):
self.pyscript_run(
r"""
<script type="py">
from pyscript import display
display('hello\nworld')
</script>
"""
)
text_content = self.page.locator("script-py").text_content()
assert "hello\nworld" == text_content
def test_amp(self):
self.pyscript_run(
r"""
<script type="py">
from pyscript import display
display('a &amp; b')
</script>
"""
)
text_content = self.page.locator("script-py").text_content()
assert "a &amp; b" == text_content
def test_quot(self):
self.pyscript_run(
r"""
<script type="py">
from pyscript import display
display('a &quot; b')
</script>
"""
)
text_content = self.page.locator("script-py").text_content()
assert "a &quot; b" == text_content
def test_lt_gt(self):
self.pyscript_run(
r"""
<script type="py">
from pyscript import display
display('< &lt; &gt; >')
</script>
"""
)
text_content = self.page.locator("script-py").text_content()
assert "< &lt; &gt; >" == text_content
def test_dynamically_add_script_type_py_tag(self):
self.pyscript_run(
"""
<script>
function addPyScriptTag() {
let tag = document.createElement('script');
tag.type = 'py';
tag.textContent = "print('hello world')";
document.body.appendChild(tag);
}
addPyScriptTag();
</script>
"""
)
# please note the test here was on timeout
# incapable of finding a <button> after the script
self.page.locator("script-py")
assert self.console.log.lines[-1] == "hello world"
def test_script_type_py_src_attribute(self):
self.writefile("foo.py", "print('hello from foo')")
self.pyscript_run(
"""
<script type="py" src="foo.py"></script>
"""
)
assert self.console.log.lines[-1] == "hello from foo"
def test_script_type_py_worker_attribute(self):
self.writefile("foo.py", "print('hello from foo')")
self.pyscript_run(
"""
<script type="py" src="foo.py" worker></script>
"""
)
assert self.console.log.lines[-1] == "hello from foo"
@pytest.mark.skip("FIXME: output attribute is not implemented")
def test_script_type_py_output_attribute(self):
self.pyscript_run(
"""
<div id="first"></div>
<script type="py" output="first">
print("<p>Hello</p>")
</script>
"""
)
text = self.page.locator("#first").text_content()
assert "<p>Hello</p>" in text
@pytest.mark.skip("FIXME: stderr attribute is not implemented")
def test_script_type_py_stderr_attribute(self):
self.pyscript_run(
"""
<div id="stdout-div"></div>
<div id="stderr-div"></div>
<script type="py" output="stdout-div" stderr="stderr-div">
import sys
print("one.", file=sys.stderr)
print("two.")
</script>
"""
)
assert self.page.locator("#stdout-div").text_content() == "one.two."
assert self.page.locator("#stderr-div").text_content() == "one."

View File

@@ -1,32 +0,0 @@
import pytest
from .support import PyScriptTest
class TestShadowRoot(PyScriptTest):
@pytest.mark.skip("NEXT: Element interface is gone. Replace with PyDom")
def test_reachable_shadow_root(self):
self.pyscript_run(
r"""
<script>
// reason to wait for py-script is that it's the entry point for
// all patches and the MutationObserver, otherwise being this a synchronous
// script the constructor gets instantly invoked at the node before
// py-script gets a chance to initialize itself.
customElements.whenDefined('py-script').then(() => {
customElements.define('s-r', class extends HTMLElement {
constructor() {
super().attachShadow({mode: 'closed'}).innerHTML =
'<div id="shadowed">OK</div>';
}
});
});
</script>
<s-r></s-r>
<script type="py">
import js
js.console.log(Element("shadowed").innerHtml)
</script>
"""
)
assert self.console.log.lines[-1] == "OK"

View File

@@ -1,122 +0,0 @@
import pytest
from playwright.sync_api import expect
from .support import PyScriptTest, skip_worker
pytest.skip(reason="NEXT: Should we remove the splashscreen?", allow_module_level=True)
class TestSplashscreen(PyScriptTest):
def test_autoshow_and_autoclose(self):
"""
By default, we show the splashscreen and we close it when the loading is
complete.
XXX: this test is a bit fragile: now it works reliably because the
startup is so slow that when we do expect(div).to_be_visible(), the
splashscreen is still there. But in theory, if the startup become very
fast, it could happen that by the time we arrive in python lang, it
has already been removed.
"""
self.pyscript_run(
"""
<script type="py">
print('hello pyscript')
</script>
""",
wait_for_pyscript=False,
)
div = self.page.locator("py-splashscreen > div")
expect(div).to_be_visible()
expect(div).to_contain_text("Python startup...")
assert "Python startup..." in self.console.info.text
#
# now we wait for the startup to complete
self.wait_for_pyscript()
#
# and now the splashscreen should have been removed
expect(div).to_be_hidden()
assert self.page.locator("py-locator").count() == 0
assert "hello pyscript" in self.console.log.lines
def test_autoclose_false(self):
self.pyscript_run(
"""
<py-config>
[splashscreen]
autoclose = false
</py-config>
<script type="py">
print('hello pyscript')
</script>
""",
)
div = self.page.locator("py-splashscreen > div")
expect(div).to_be_visible()
expect(div).to_contain_text("Python startup...")
expect(div).to_contain_text("Startup complete")
assert "hello pyscript" in self.console.log.lines
def test_autoclose_loader_deprecated(self):
self.pyscript_run(
"""
<py-config>
autoclose_loader = false
</py-config>
<script type="py">
print('hello pyscript')
</script>
""",
)
warning = self.page.locator(".py-warning")
inner_text = warning.inner_html()
assert "The setting autoclose_loader is deprecated" in inner_text
div = self.page.locator("py-splashscreen > div")
expect(div).to_be_visible()
expect(div).to_contain_text("Python startup...")
expect(div).to_contain_text("Startup complete")
assert "hello pyscript" in self.console.log.lines
def test_splashscreen_disabled_option(self):
self.pyscript_run(
"""
<py-config>
[splashscreen]
enabled = false
</py-config>
<script type="py">
def test():
print("Hello pyscript!")
test()
</script>
""",
)
assert self.page.locator("py-splashscreen").count() == 0
assert self.console.log.lines[-1] == "Hello pyscript!"
py_terminal = self.page.wait_for_selector("py-terminal")
assert py_terminal.inner_text() == "Hello pyscript!\n"
@skip_worker("FIXME: js.document")
def test_splashscreen_custom_message(self):
self.pyscript_run(
"""
<py-config>
[splashscreen]
autoclose = false
</py-config>
<script type="py">
from js import document
splashscreen = document.querySelector("py-splashscreen")
splashscreen.log("Hello, world!")
</script>
""",
)
splashscreen = self.page.locator("py-splashscreen")
assert splashscreen.count() == 1
assert "Hello, world!" in splashscreen.inner_text()

View File

@@ -1,370 +0,0 @@
import pytest
from .support import PyScriptTest, skip_worker
pytest.skip(reason="NEXT: entire stdio should be reviewed", allow_module_level=True)
class TestOutputHandling(PyScriptTest):
# Source of a script to test the TargetedStdio functionality
def test_targeted_stdio_solo(self):
self.pyscript_run(
"""
<py-config>
terminal = true
</py-config>
<py-terminal></py-terminal>
<div id="container">
<div id="first"></div>
<div id="second"></div>
<div id="third"></div>
</div>
<script type="py" output="first">print("first 1.")</script>
<script type="py" output="second">print("second.")</script>
<script type="py" output="third">print("third.")</script>
<script type="py" output="first">print("first 2.")</script>
<script type="py">print("no output.")</script>
"""
)
# Check that page has desired parent/child structure, and that
# Output divs are correctly located
assert (container := self.page.locator("#container")).count() > 0
assert (first_div := container.locator("#first")).count() > 0
assert (second_div := container.locator("#second")).count() > 0
assert (third_div := container.locator("#third")).count() > 0
# Check that output ends up in proper div
assert first_div.text_content() == "first 1.first 2."
assert second_div.text_content() == "second."
assert third_div.text_content() == "third."
# Check that tag with no otuput attribute doesn't end up in container at all
assert container.get_by_text("no output.").count() == 0
# Check that all output ends up in py-terminal
assert (
self.page.locator("py-terminal").text_content()
== "first 1.second.third.first 2.no output."
)
# Check that all output ends up in the dev console, in order
last_index = -1
for line in ["first 1.", "second.", "third.", "first 2.", "no output."]:
assert (line_index := self.console.log.lines.index(line)) > -1
assert line_index > last_index
last_index = line_index
self.assert_no_banners()
def test_stdio_escape(self):
# Test that text that looks like HTML tags is properly escaped in stdio
self.pyscript_run(
"""
<div id="first"></div>
<script type="py" output="first">
print("<p>Hello</p>")
print('<img src="https://example.net">')
</script>
"""
)
text = self.page.locator("#first").text_content()
assert "<p>Hello</p>" in text
assert '<img src="https://example.net">' in text
self.assert_no_banners()
def test_targeted_stdio_linebreaks(self):
self.pyscript_run(
"""
<div id="first"></div>
<script type="py" output="first">
print("one.")
print("two.")
print("three.")
</script>
<div id="second"></div>
<script type="py" output="second">
print("one.\\ntwo.\\nthree.")
</script>
"""
)
# check line breaks at end of each input
assert self.page.locator("#first").inner_html() == "one.<br>two.<br>three.<br>"
# new lines are converted to line breaks
assert self.page.locator("#second").inner_html() == "one.<br>two.<br>three.<br>"
self.assert_no_banners()
def test_targeted_stdio_async(self):
# Test the behavior of stdio capture in async contexts
self.pyscript_run(
"""
<script type="py">
import asyncio
import js
async def coro(value, delay):
print(value)
await asyncio.sleep(delay)
js.console.log(f"DONE {value}")
</script>
<div id="first"></div>
<script type="py">
asyncio.ensure_future(coro("first", 1))
</script>
<div id="second"></div>
<script type="py" output="second">
asyncio.ensure_future(coro("second", 1))
</script>
<div id="third"></div>
<script type="py" output="third">
asyncio.ensure_future(coro("third", 0))
</script>
<script type="py" output="third">
asyncio.ensure_future(coro("DONE", 3))
</script>
"""
)
self.wait_for_console("DONE DONE")
# script tags without output parameter should not send
# stdout to element
assert self.page.locator("#first").text_content() == ""
# script tags with output parameter not expected to send
# std to element in coroutine
assert self.page.locator("#second").text_content() == ""
assert self.page.locator("#third").text_content() == ""
self.assert_no_banners()
def test_targeted_stdio_interleaved(self):
# Test that synchronous writes to stdout are placed correctly, even
# While interleaved with scheduling coroutines in the same tag
self.pyscript_run(
"""
<div id="good"></div>
<div id="bad"></div>
<script type="py" output="good">
import asyncio
import js
async def coro_bad(value, delay):
print(value)
await asyncio.sleep(delay)
print("one.")
asyncio.ensure_future(coro_bad("badone.", 0.1))
print("two.")
asyncio.ensure_future(coro_bad("badtwo.", 0.2))
print("three.")
asyncio.ensure_future(coro_bad("badthree.", 0))
asyncio.ensure_future(coro_bad("DONE", 1))
</script>
"""
)
# Three prints should appear from synchronous writes
assert self.page.locator("#good").text_content() == "one.two.three."
# Check that all output ends up in the dev console, in order
last_index = -1
for line in ["one.", "two.", "three.", "badthree.", "badone.", "badtwo."]:
assert (line_index := self.console.log.lines.index(line)) > -1
assert line_index > last_index
self.assert_no_banners()
@skip_worker("FIXME: js.document")
def test_targeted_stdio_dynamic_tags(self):
# Test that creating py-script tags via Python still leaves
# stdio targets working
self.pyscript_run(
"""
<div id="first"></div>
<div id="second"></div>
<script type="py" output="first">
print("first.")
import js
tag = js.document.createElement("py-script")
tag.innerText = "print('second.')"
tag.setAttribute("output", "second")
js.document.body.appendChild(tag)
print("first.")
</script>
"""
)
# Ensure second tag was added to page
assert (second_div := self.page.locator("#second")).count() > 0
# Ensure output when to correct locations
assert self.page.locator("#first").text_content() == "first.first."
assert second_div.text_content() == "second."
self.assert_no_banners()
def test_stdio_stdout_id_errors(self):
# Test that using an ID not present on the page as the Output
# Attribute creates exactly 1 warning banner per missing id
self.pyscript_run(
"""
<script type="py" output="not-on-page">
print("bad.")
</script>
<div id="on-page"></div>
<script type="py">
print("good.")
</script>
<script type="py" output="not-on-page">
print("bad.")
</script>
"""
)
banner = self.page.query_selector_all(".py-warning")
assert len(banner) == 1
banner_content = banner[0].inner_text()
expected = (
'output = "not-on-page" does not match the id of any element on the page.'
)
assert banner_content == expected
def test_stdio_stderr_id_errors(self):
# Test that using an ID not present on the page as the stderr
# attribute creates exactly 1 warning banner per missing id
self.pyscript_run(
"""
<script type="py" stderr="not-on-page">
import sys
print("bad.", file=sys.stderr)
</script>
<div id="on-page"></div>
<script type="py">
print("good.", file=sys.stderr)
</script>
<script type="py" stderr="not-on-page">
print("bad.", file=sys.stderr)
</script>
"""
)
banner = self.page.query_selector_all(".py-warning")
assert len(banner) == 1
banner_content = banner[0].inner_text()
expected = (
'stderr = "not-on-page" does not match the id of any element on the page.'
)
assert banner_content == expected
def test_stdio_stderr(self):
# Test that stderr works, and routes to the same location as stdout
# Also, script tags with the stderr attribute route to an additional location
self.pyscript_run(
"""
<div id="stdout-div"></div>
<div id="stderr-div"></div>
<script type="py" output="stdout-div" stderr="stderr-div">
import sys
print("one.", file=sys.stderr)
print("two.")
</script>
"""
)
assert self.page.locator("#stdout-div").text_content() == "one.two."
assert self.page.locator("#stderr-div").text_content() == "one."
self.assert_no_banners()
@skip_worker("FIXME: js.document")
def test_stdio_output_attribute_change(self):
# If the user changes the 'output' attribute of a <script type="py"> tag mid-execution,
# Output should no longer go to the selected div and a warning should appear
self.pyscript_run(
"""
<div id="first"></div>
<div id="second"></div>
<!-- There is no tag with id "third" -->
<script type="py" id="pyscript-tag" output="first">
print("one.")
# Change the 'output' attribute of this tag
import js
this_tag = js.document.getElementById("pyscript-tag")
this_tag.setAttribute("output", "second")
print("two.")
this_tag.setAttribute("output", "third")
print("three.")
</script>
"""
)
assert self.page.locator("#first").text_content() == "one."
assert self.page.locator("#second").text_content() == "two."
expected_alert_banner_msg = (
'output = "third" does not match the id of any element on the page.'
)
alert_banner = self.page.locator(".alert-banner")
assert expected_alert_banner_msg in alert_banner.inner_text()
@skip_worker("FIXME: js.document")
def test_stdio_target_element_id_change(self):
# If the user changes the ID of the targeted DOM element mid-execution,
# Output should no longer go to the selected element and a warning should appear
self.pyscript_run(
"""
<div id="first"></div>
<div id="second"></div>
<!-- There is no tag with id "third" -->
<script type="py" id="pyscript-tag" output="first">
print("one.")
# Change the ID of the targeted DIV to something else
import js
target_tag = js.document.getElementById("first")
# should fail and show banner
target_tag.setAttribute("id", "second")
print("two.")
# But changing both the 'output' attribute and the id of the target
# should work
target_tag.setAttribute("id", "third")
js.document.getElementById("pyscript-tag").setAttribute("output", "third")
print("three.")
</script>
"""
)
# Note the ID of the div has changed by the time of this assert
assert self.page.locator("#third").text_content() == "one.three."
expected_alert_banner_msg = (
'output = "first" does not match the id of any element on the page.'
)
alert_banner = self.page.locator(".alert-banner")
assert expected_alert_banner_msg in alert_banner.inner_text()

View File

@@ -1,25 +0,0 @@
import pytest
from playwright.sync_api import expect
from .support import PyScriptTest, with_execution_thread
@with_execution_thread(None)
class TestStyle(PyScriptTest):
def test_pyscript_not_defined(self):
"""Test raw elements that are not defined for display:none"""
doc = """
<html>
<head>
<link rel="stylesheet" href="build/core.css" />
</head>
<body>
<py-config>hello</py-config>
<py-script>hello</script>
</body>
</html>
"""
self.writefile("test-not-defined-css.html", doc)
self.goto("test-not-defined-css.html")
expect(self.page.locator("py-config")).to_be_hidden()
expect(self.page.locator("py-script")).to_be_hidden()

View File

@@ -1,54 +0,0 @@
import pytest
from .support import PyScriptTest, skip_worker
class TestWarningsAndBanners(PyScriptTest):
# Test the behavior of generated warning banners
def test_deprecate_loading_scripts_from_latest(self):
# Use a script tag with an invalid output attribute to generate a warning, but only one
self.pyscript_run(
"""
<script type="py">
print("whatever..")
</script>
""",
extra_head='<script type="ignore-me" src="https://pyscript.net/latest/any-path-triggers-the-warning-anyway.js"></script>',
)
# wait for the banner to appear (we could have a page.locater call but for some reason
# the worker takes to long to render on CI, since it's a test we can afford 2 calls)
loc = self.page.wait_for_selector(".py-error")
assert (
loc.inner_text()
== "Loading scripts from latest is deprecated and will be removed soon. Please use a specific version instead."
)
# Only one banner should appear
loc = self.page.locator(".py-error")
assert loc.count() == 1
@pytest.mark.skip("NEXT: To check if behaviour is consistent with classic")
def test_create_singular_warning(self):
# Use a script tag with an invalid output attribute to generate a warning, but only one
self.pyscript_run(
"""
<script type="py" output="foo">
print("one.")
print("two.")
</script>
<script type="py" output="foo">
print("three.")
</script>
"""
)
loc = self.page.locator(".alert-banner")
# Only one banner should appear
assert loc.count() == 1
assert (
loc.text_content()
== 'output = "foo" does not match the id of any element on the page.'
)

View File

@@ -1,178 +0,0 @@
import pytest
from .support import PyScriptTest, skip_worker
class TestEventHandler(PyScriptTest):
def test_when_decorator_with_event(self):
"""When the decorated function takes a single parameter,
it should be passed the event object
"""
self.pyscript_run(
"""
<button id="foo_id">foo_button</button>
<script type="py">
from pyscript import when
@when("click", selector="#foo_id")
def foo(evt):
print(f"clicked {evt.target.id}")
</script>
"""
)
self.page.locator("text=foo_button").click()
self.wait_for_console("clicked foo_id")
self.assert_no_banners()
def test_when_decorator_without_event(self):
"""When the decorated function takes no parameters (not including 'self'),
it should be called without the event object
"""
self.pyscript_run(
"""
<button id="foo_id">foo_button</button>
<script type="py">
from pyscript import when
@when("click", selector="#foo_id")
def foo():
print("The button was clicked")
</script>
"""
)
self.page.locator("text=foo_button").click()
self.wait_for_console("The button was clicked")
self.assert_no_banners()
def test_multiple_when_decorators_with_event(self):
self.pyscript_run(
"""
<button id="foo_id">foo_button</button>
<button id="bar_id">bar_button</button>
<script type="py">
from pyscript import when
@when("click", selector="#foo_id")
def foo_click(evt):
print(f"foo_click! id={evt.target.id}")
@when("click", selector="#bar_id")
def bar_click(evt):
print(f"bar_click! id={evt.target.id}")
</script>
"""
)
self.page.locator("text=foo_button").click()
self.wait_for_console("foo_click! id=foo_id")
self.page.locator("text=bar_button").click()
self.wait_for_console("bar_click! id=bar_id")
self.assert_no_banners()
def test_two_when_decorators(self):
"""When decorating a function twice, both should function"""
self.pyscript_run(
"""
<button id="foo_id">foo_button</button>
<button class="bar_class">bar_button</button>
<script type="py">
from pyscript import when
@when("click", selector="#foo_id")
@when("mouseover", selector=".bar_class")
def foo(evt):
print(f"got event: {evt.type}")
</script>
"""
)
self.page.locator("text=bar_button").hover()
self.wait_for_console("got event: mouseover")
self.page.locator("text=foo_button").click()
self.wait_for_console("got event: click")
self.assert_no_banners()
def test_two_when_decorators_same_element(self):
"""When decorating a function twice *on the same DOM element*, both should function"""
self.pyscript_run(
"""
<button id="foo_id">foo_button</button>
<script type="py">
from pyscript import when
@when("click", selector="#foo_id")
@when("mouseover", selector="#foo_id")
def foo(evt):
print(f"got event: {evt.type}")
</script>
"""
)
self.page.locator("text=foo_button").hover()
self.wait_for_console("got event: mouseover")
self.page.locator("text=foo_button").click()
self.wait_for_console("got event: click")
self.assert_no_banners()
def test_when_decorator_multiple_elements(self):
"""The @when decorator's selector should successfully select multiple
DOM elements
"""
self.pyscript_run(
"""
<button class="bar_class">button1</button>
<button class="bar_class">button2</button>
<script type="py">
from pyscript import when
@when("click", selector=".bar_class")
def foo(evt):
print(f"{evt.target.innerText} was clicked")
</script>
"""
)
self.page.locator("text=button1").click()
self.page.locator("text=button2").click()
self.wait_for_console("button2 was clicked")
assert "button1 was clicked" in self.console.log.lines
assert "button2 was clicked" in self.console.log.lines
self.assert_no_banners()
def test_when_decorator_duplicate_selectors(self):
""" """
self.pyscript_run(
"""
<button id="foo_id">foo_button</button>
<script type="py">
from pyscript import when
@when("click", selector="#foo_id")
@when("click", selector="#foo_id")
def foo(evt):
foo.n += 1
print(f"click {foo.n} on {evt.target.id}")
foo.n = 0
</script>
"""
)
self.page.locator("text=foo_button").click()
self.wait_for_console("click 1 on foo_id")
self.wait_for_console("click 2 on foo_id")
self.assert_no_banners()
@skip_worker("NEXT: error banner not shown")
def test_when_decorator_invalid_selector(self):
"""When the selector parameter of @when is invalid, it should show an error"""
self.pyscript_run(
"""
<button id="foo_id">foo_button</button>
<script type="py">
from pyscript import when
@when("click", selector="#.bad")
def foo(evt):
...
</script>
"""
)
self.page.locator("text=foo_button").click()
msg = "Failed to execute 'querySelectorAll' on 'Document': '#.bad' is not a valid selector."
error = self.page.wait_for_selector(".py-error")
banner_text = error.inner_text()
if msg not in banner_text:
raise AssertionError(
f"Expected message '{msg}' does not "
f"match banner text '{banner_text}'"
)
assert msg in self.console.error.lines[-1]
self.check_py_errors(msg)

View File

@@ -0,0 +1,4 @@
packages = [
"https://cdn.holoviz.org/panel/wheels/bokeh-3.5.0-py3-none-any.whl",
"https://cdn.holoviz.org/panel/1.5.0-b.2/dist/wheels/panel-1.5.0b2-py3-none-any.whl"
]

View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="../../dist/core.css">
<script src="https://cdn.bokeh.org/bokeh/release/bokeh-3.5.0.js"></script>
<script src="https://cdn.bokeh.org/bokeh/release/bokeh-widgets-3.5.0.min.js"></script>
<script src="https://cdn.bokeh.org/bokeh/release/bokeh-tables-3.5.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@holoviz/panel@1.5.0-b.2/dist/panel.min.js"></script>
<script type="module" src="../../dist/core.js"></script>
</head>
<body>
<script type="py" src="main.py" config="config.toml" worker></script>
<div id="simple_app"></div>
</body>
</html>

View File

@@ -0,0 +1,12 @@
import panel as pn
pn.extension(sizing_mode="stretch_width")
slider = pn.widgets.FloatSlider(start=0, end=10, name="amplitude")
def callback(new):
return f"Amplitude is: {new}"
pn.Row(slider, pn.bind(callback, slider)).servable(target="simple_app")

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="../../dist/core.css">
<script type="module" src="../../dist/core.js"></script>
</head>
<body>
<script type="mpy">
from pyscript import window, document, fetch, when
@when('click', '#click')
async def click(event):
text = await fetch(window.location.href).text()
document.getElementById('output').append(text)
document.documentElement.classList.add('ok')
document.getElementById('click').click()
</script>
<button id="click">click</button>
<pre id="output"></pre>
</body>
</html>

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PyScript Next Plugin</title>
<link rel="stylesheet" href="../dist/core.css">
<script type="module" src="../dist/core.js"></script>
<link rel="stylesheet" href="../../dist/core.css">
<script type="module" src="../../dist/core.js"></script>
<mpy-config src="config-url/config.json"></mpy-config>
<script type="mpy">
from pyscript import config

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="../../dist/core.css">
<script type="module" src="../../dist/core.js"></script>
</head>
<body>
<script type="mpy">
from pyscript import config, document
if config["type"] is "mpy":
document.documentElement.classList.add("mpy")
</script>
<script type="py">
from pyscript import config, document
if config["type"] is "py":
document.documentElement.classList.add("py")
</script>
</body>
</html>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="../dist/core.css">
<link rel="stylesheet" href="../../../dist/core.css">
</head>
<body>
<script type="module">
@@ -18,7 +18,7 @@
document.createElement('script'),
{
type: 'module',
src: '../dist/core.js'
src: '../../../dist/core.js'
}
)
);

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PyScript FFI</title>
<link rel="stylesheet" href="../dist/core.css">
<script type="module" src="../dist/core.js"></script>
<link rel="stylesheet" href="../../dist/core.css">
<script type="module" src="../../dist/core.js"></script>
</head>
<body>
<script type="mpy">

View File

@@ -4,13 +4,13 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PyScript Next Plugin Bug?</title>
<link rel="stylesheet" href="../dist/core.css">
<link rel="stylesheet" href="../../dist/core.css">
<script type="module">
addEventListener('mpy:done', () => {
document.documentElement.classList.add('done');
});
import { hooks } from "../dist/core.js";
import { hooks } from "../../dist/core.js";
// Main
hooks.main.onReady.add((wrap, element) => {
@@ -48,12 +48,12 @@
</script>
</head>
<body>
<script type="mpy" worker>
<script type="mpy" async="false" worker>
from pyscript import document
print("actual code in worker")
document.documentElement.classList.add('worker')
</script>
<script type="mpy">
<script type="mpy" async="false">
print("actual code in main")
</script>
</body>

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