mirror of
https://github.com/pyscript/pyscript.git
synced 2025-12-19 10:17:23 -05:00
pyscript API/docstring refactor with comprehensive tests (#2414)
* Revise display module. TODO: more comprehensive tests. Especially around mimebundles. * Markdown corrections in example code in display.py docstrings. * Minor adjustments and a much more comprehensive test-suite for the display module. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Updated docstring in __init__.py. * Remove unused imports and black-ify the source. * Refactor, docs and tests for the Event class in events.py. * Refactored, simplified and documented @when decorator. * Extensive test suite for @when decorator. * Documentation and minor refactoring of the fetch.py module. TODO: Check tests. * Refactored and more comprehensive tests for the fetch module. * Add/clarify Event related interactions. Thanks @Neon22 for the suggestion. * Refactor, document ffi.py module. * More complete passing tests for ffi.py. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add docstrings to flatted.py. Since this is actually an external(ish) module, tests for it should be in the external repository from which this code is derived. * Minor docstring cleanup in ffi.py. * Added docstrings and clarifications to fs.py. * Add very limited test suite for fs.py. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Rename magic_js.py to context.py, add comprehensive docstrings, and rename certain internal things for readability and comprehension. * Fix dict check in ffi.py. * Rename test_js_modules to test_context. * Fix test configuration aftert rename. * Docs and refactor of media.py. * Comprehensive tests for media.py. * Refactor and docstrings for storage.py * Appease the ruff gods. * Further storage.py changes and a more complete test suite for storage. * Refactor and docstrings for the util.py module. Fixed a problem with is_awaitable not handling async bound methods. * More comprehensive test suite for util.py. Updated to latest upytest. * A major refactoring, documenting and simplification of the web.py module substantially reducing it in size and complexity with only a few minor (edge) behavioural changes. Softly breaking changes include: - An element's classes are just a set. - An element's styles are just a dict. - Explicitly use `update_all` with ElementCollections (simpler and greater flexibility). - Extract a child element by id with `my_container["#an-id"]` * Updates and additions for a more comprehensive test suite for the web.py module. All code paths are exercised and checked. * Black tidy-ups in test suite. * Refactor and documentation for websocket.py module. * Tests for websocket.py. Disabled due to playwright flakiness, but they all pass in a local browser. * Refactor and documentation of workers.py module. * Added tests for workers.py module. Updated related test suite to account for the new named worker in the test HTML. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Refactor away remaining "is not None" not caught before. * Remove check-docstring-first because it interferes with the auto-generated documentation (where triple quoted strings are used to document module attributes). * Careful Markdown changes so the docstrings render properly in the PyScript docs. * Typo correction. * More typo corrections and clarifications. * Add clarification about SVG handling to _render_image docstring. * Add DOM event options to the @when decorator (with new tests to exercise this functionality). * Fixes default value for options if no options passed into @when. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
90ae3cea95
commit
a02ff691d2
@@ -11,7 +11,6 @@ repos:
|
||||
hooks:
|
||||
- id: check-builtin-literals
|
||||
- id: check-case-conflict
|
||||
- id: check-docstring-first
|
||||
- id: check-executables-have-shebangs
|
||||
- id: check-json
|
||||
exclude: tsconfig\.json
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,36 +1,105 @@
|
||||
# Some notes about the naming conventions and the relationship between various
|
||||
# similar-but-different names.
|
||||
#
|
||||
# import pyscript
|
||||
# this package contains the main user-facing API offered by pyscript. All
|
||||
# the names which are supposed be used by end users should be made
|
||||
# available in pyscript/__init__.py (i.e., this file)
|
||||
#
|
||||
# import _pyscript
|
||||
# this is an internal module implemented in JS. It is used internally by
|
||||
# the pyscript package, end users should not use it directly. For its
|
||||
# implementation, grep for `interpreter.registerJsModule("_pyscript",
|
||||
# ...)` in core.js
|
||||
#
|
||||
# import js
|
||||
# this is the JS globalThis, as exported by pyodide and/or micropython's
|
||||
# FFIs. As such, it contains different things in the main thread or in a
|
||||
# worker.
|
||||
#
|
||||
# import pyscript.magic_js
|
||||
# this submodule abstracts away some of the differences between the main
|
||||
# thread and the worker. In particular, it defines `window` and `document`
|
||||
# in such a way that these names work in both cases: in the main thread,
|
||||
# they are the "real" objects, in the worker they are proxies which work
|
||||
# thanks to coincident.
|
||||
#
|
||||
# from pyscript import window, document
|
||||
# these are just the window and document objects as defined by
|
||||
# pyscript.magic_js. This is the blessed way to access them from pyscript,
|
||||
# as it works transparently in both the main thread and worker cases.
|
||||
"""
|
||||
This is the main `pyscript` namespace. It provides the primary Pythonic API
|
||||
for users to interact with the
|
||||
[browser's own API](https://developer.mozilla.org/en-US/docs/Web/API). It
|
||||
includes utilities for common activities such as displaying content, handling
|
||||
events, fetching resources, managing local storage, and coordinating with
|
||||
web workers.
|
||||
|
||||
The most important names provided by this namespace can be directly imported
|
||||
from `pyscript`, for example:
|
||||
|
||||
```python
|
||||
from pyscript import display, HTML, fetch, when, storage, WebSocket
|
||||
```
|
||||
|
||||
The following names are available in the `pyscript` namespace:
|
||||
|
||||
- `RUNNING_IN_WORKER`: Boolean indicating if the code is running in a Web
|
||||
Worker.
|
||||
- `PyWorker`: Class for creating Web Workers running Python code.
|
||||
- `config`: Configuration object for pyscript settings.
|
||||
- `current_target`: The element in the DOM that is the current target for
|
||||
output.
|
||||
- `document`: The standard `document` object, proxied in workers.
|
||||
- `window`: The standard `window` object, proxied in workers.
|
||||
- `js_import`: Function to dynamically import JS modules.
|
||||
- `js_modules`: Object containing JS modules available to Python.
|
||||
- `sync`: Utility for synchronizing between worker and main thread.
|
||||
- `display`: Function to render Python objects in the web page.
|
||||
- `HTML`: Helper class to create HTML content for display.
|
||||
- `fetch`: Function to perform HTTP requests.
|
||||
- `Storage`: Class representing browser storage (local/session).
|
||||
- `storage`: Object to interact with browser's local storage.
|
||||
- `WebSocket`: Class to create and manage WebSocket connections.
|
||||
- `when`: Function to register event handlers on DOM elements.
|
||||
- `Event`: Class representing user defined or DOM events.
|
||||
- `py_import`: Function to lazily import Pyodide related Python modules.
|
||||
|
||||
If running in the main thread, the following additional names are available:
|
||||
|
||||
- `create_named_worker`: Function to create a named Web Worker.
|
||||
- `workers`: Object to manage and interact with existing Web Workers.
|
||||
|
||||
All of these names are defined in the various submodules of `pyscript` and
|
||||
are imported and re-exported here for convenience. Please refer to the
|
||||
respective submodule documentation for more details on each component.
|
||||
|
||||
|
||||
!!! Note
|
||||
Some notes about the naming conventions and the relationship between
|
||||
various similar-but-different names found within this code base.
|
||||
|
||||
```python
|
||||
import pyscript
|
||||
```
|
||||
|
||||
The `pyscript` package contains the main user-facing API offered by
|
||||
PyScript. All the names which are supposed be used by end users should
|
||||
be made available in `pyscript/__init__.py` (i.e., this source file).
|
||||
|
||||
```python
|
||||
import _pyscript
|
||||
```
|
||||
|
||||
The `_pyscript` module is an internal API implemented in JS. **End users
|
||||
should not use it directly**. For its implementation, grep for
|
||||
`interpreter.registerJsModule("_pyscript",...)` in `core.js`.
|
||||
|
||||
```python
|
||||
import js
|
||||
```
|
||||
|
||||
The `js` object is
|
||||
[the JS `globalThis`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/globalThis),
|
||||
as exported by Pyodide and/or Micropython's foreign function interface
|
||||
(FFI). As such, it contains different things in the main thread or in a
|
||||
worker, as defined by web standards.
|
||||
|
||||
```python
|
||||
import pyscript.context
|
||||
```
|
||||
|
||||
The `context` submodule abstracts away some of the differences between
|
||||
the main thread and a worker. Its most important features are made
|
||||
available in the root `pyscript` namespace. All other functionality is
|
||||
mostly for internal PyScript use or advanced users. In particular, it
|
||||
defines `window` and `document` in such a way that these names work in
|
||||
both cases: in the main thread, they are the "real" objects, in a worker
|
||||
they are proxies which work thanks to
|
||||
[coincident](https://github.com/WebReflection/coincident).
|
||||
|
||||
```python
|
||||
from pyscript import window, document
|
||||
```
|
||||
|
||||
These are just the `window` and `document` objects as defined by
|
||||
`pyscript.context`. This is the blessed way to access them from `pyscript`,
|
||||
as it works transparently in both the main thread and worker cases.
|
||||
"""
|
||||
|
||||
from polyscript import lazy_py_modules as py_import
|
||||
from pyscript.magic_js import (
|
||||
from pyscript.context import (
|
||||
RUNNING_IN_WORKER,
|
||||
PyWorker,
|
||||
config,
|
||||
|
||||
198
core/src/stdlib/pyscript/context.py
Normal file
198
core/src/stdlib/pyscript/context.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
Execution context management for PyScript.
|
||||
|
||||
This module handles the differences between running in the
|
||||
[main browser thread](https://developer.mozilla.org/en-US/docs/Glossary/Main_thread)
|
||||
versus running in a
|
||||
[Web Worker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers),
|
||||
providing a consistent API regardless of the execution context.
|
||||
|
||||
Key features:
|
||||
|
||||
- Detects whether code is running in a worker or main thread. Read this via
|
||||
the boolean `pyscript.context.RUNNING_IN_WORKER`.
|
||||
- Parses and normalizes configuration from `polyscript.config` and adds the
|
||||
Python interpreter type via the `type` key in `pyscript.context.config`.
|
||||
- Provides appropriate implementations of `window`, `document`, and `sync`.
|
||||
- Sets up JavaScript module import system, including a lazy `js_import`
|
||||
function.
|
||||
- Manages `PyWorker` creation.
|
||||
- Provides access to the current display target via
|
||||
`pyscript.context.display_target`.
|
||||
|
||||
!!! warning
|
||||
|
||||
These are key differences between the main thread and worker contexts:
|
||||
|
||||
Main thread context:
|
||||
|
||||
- `window` and `document` are available directly.
|
||||
- `PyWorker` can be created to spawn worker threads.
|
||||
- `sync` is not available (raises `NotSupported`).
|
||||
|
||||
Worker context:
|
||||
|
||||
- `window` and `document` are proxied from main thread (if SharedArrayBuffer
|
||||
available).
|
||||
- `PyWorker` is not available (raises `NotSupported`).
|
||||
- `sync` utilities are available for main thread communication.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import js
|
||||
from polyscript import config as _polyscript_config
|
||||
from polyscript import js_modules
|
||||
from pyscript.util import NotSupported
|
||||
|
||||
RUNNING_IN_WORKER = not hasattr(js, "document")
|
||||
"""Detect execution context: True if running in a worker, False if main thread."""
|
||||
|
||||
config = json.loads(js.JSON.stringify(_polyscript_config))
|
||||
"""Parsed and normalized configuration."""
|
||||
if isinstance(config, str):
|
||||
config = {}
|
||||
|
||||
js_import = None
|
||||
"""Function to import JavaScript modules dynamically."""
|
||||
|
||||
window = None
|
||||
"""The `window` object (proxied if in a worker)."""
|
||||
|
||||
document = None
|
||||
"""The `document` object (proxied if in a worker)."""
|
||||
|
||||
sync = None
|
||||
"""Sync utilities for worker-main thread communication (only in workers)."""
|
||||
|
||||
# Detect and add Python interpreter type to config.
|
||||
if "MicroPython" in sys.version:
|
||||
config["type"] = "mpy"
|
||||
else:
|
||||
config["type"] = "py"
|
||||
|
||||
|
||||
class _JSModuleProxy:
|
||||
"""
|
||||
Proxy for JavaScript modules imported via js_modules.
|
||||
|
||||
This allows Python code to import JavaScript modules using Python's
|
||||
import syntax:
|
||||
|
||||
```python
|
||||
from pyscript.js_modules lodash import debounce
|
||||
```
|
||||
|
||||
The proxy lazily retrieves the actual JavaScript module when accessed.
|
||||
"""
|
||||
|
||||
def __init__(self, name):
|
||||
"""
|
||||
Create a proxy for the named JavaScript module.
|
||||
"""
|
||||
self.name = name
|
||||
|
||||
def __getattr__(self, field):
|
||||
"""
|
||||
Retrieve a JavaScript object/function from the proxied JavaScript
|
||||
module via the given `field` name.
|
||||
"""
|
||||
# Avoid Pyodide looking for non-existent special methods.
|
||||
if not field.startswith("_"):
|
||||
return getattr(getattr(js_modules, self.name), field)
|
||||
return None
|
||||
|
||||
|
||||
# Register all available JavaScript modules in Python's module system.
|
||||
# This enables: from pyscript.js_modules.xxx import yyy
|
||||
for module_name in js.Reflect.ownKeys(js_modules):
|
||||
sys.modules[f"pyscript.js_modules.{module_name}"] = _JSModuleProxy(module_name)
|
||||
sys.modules["pyscript.js_modules"] = js_modules
|
||||
|
||||
|
||||
# Context-specific setup: Worker vs Main Thread.
|
||||
if RUNNING_IN_WORKER:
|
||||
import polyscript
|
||||
|
||||
# PyWorker cannot be created from within a worker.
|
||||
PyWorker = NotSupported(
|
||||
"pyscript.PyWorker",
|
||||
"pyscript.PyWorker works only when running in the main thread",
|
||||
)
|
||||
|
||||
# Attempt to access main thread's window and document via SharedArrayBuffer.
|
||||
try:
|
||||
window = polyscript.xworker.window
|
||||
document = window.document
|
||||
js.document = document
|
||||
|
||||
# Create js_import function that runs imports on the main thread.
|
||||
js_import = window.Function(
|
||||
"return (...urls) => Promise.all(urls.map((url) => import(url)))"
|
||||
)()
|
||||
|
||||
except:
|
||||
# SharedArrayBuffer not available - window/document cannot be proxied.
|
||||
sab_error_message = (
|
||||
"Unable to use `window` or `document` in worker. "
|
||||
"This requires SharedArrayBuffer support. "
|
||||
"See: https://docs.pyscript.net/latest/faq/#sharedarraybuffer"
|
||||
)
|
||||
js.console.warn(sab_error_message)
|
||||
window = NotSupported("pyscript.window", sab_error_message)
|
||||
document = NotSupported("pyscript.document", sab_error_message)
|
||||
|
||||
# Worker-specific utilities for main thread communication.
|
||||
sync = polyscript.xworker.sync
|
||||
|
||||
def current_target():
|
||||
"""
|
||||
Get the current output target in worker context.
|
||||
"""
|
||||
return polyscript.target
|
||||
|
||||
else:
|
||||
# Main thread context setup.
|
||||
import _pyscript
|
||||
from _pyscript import PyWorker as _PyWorker
|
||||
from pyscript.ffi import to_js
|
||||
|
||||
js_import = _pyscript.js_import
|
||||
|
||||
def PyWorker(url, **options):
|
||||
"""
|
||||
Create a Web Worker running Python code.
|
||||
|
||||
This spawns a new worker thread that can execute Python code
|
||||
found at the `url`, independently of the main thread. The
|
||||
`**options` can be used to configure the worker.
|
||||
|
||||
```python
|
||||
from pyscript import PyWorker
|
||||
|
||||
|
||||
# Create a worker to run background tasks.
|
||||
# (`type` MUST be either `micropython` or `pyodide`)
|
||||
worker = PyWorker("./worker.py", type="micropython")
|
||||
```
|
||||
|
||||
PyWorker **can only be created from the main thread**, not from
|
||||
within another worker.
|
||||
"""
|
||||
return _PyWorker(url, to_js(options))
|
||||
|
||||
# Main thread has direct access to window and document.
|
||||
window = js
|
||||
document = js.document
|
||||
|
||||
# sync is not available in main thread (only in workers).
|
||||
sync = NotSupported(
|
||||
"pyscript.sync", "pyscript.sync works only when running in a worker"
|
||||
)
|
||||
|
||||
def current_target():
|
||||
"""
|
||||
Get the current output target in main thread context.
|
||||
"""
|
||||
return _pyscript.target
|
||||
@@ -1,64 +1,104 @@
|
||||
"""
|
||||
Display Pythonic content in the browser.
|
||||
|
||||
This module provides the `display()` function for rendering Python objects
|
||||
in the web page. The function introspects objects to determine the appropriate
|
||||
[MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/MIME_types/Common_types)
|
||||
and rendering method.
|
||||
|
||||
Supported MIME types:
|
||||
|
||||
- `text/plain`: Plain text (HTML-escaped)
|
||||
- `text/html`: HTML content
|
||||
- `image/png`: PNG images as data URLs
|
||||
- `image/jpeg`: JPEG images as data URLs
|
||||
- `image/svg+xml`: SVG graphics
|
||||
- `application/json`: JSON data
|
||||
- `application/javascript`: JavaScript code (discouraged)
|
||||
|
||||
The `display()` function uses standard Python representation methods
|
||||
(`_repr_html_`, `_repr_png_`, etc.) to determine how to render objects.
|
||||
Objects can provide a `_repr_mimebundle_` method to specify preferred formats
|
||||
like this:
|
||||
|
||||
```python
|
||||
def _repr_mimebundle_(self):
|
||||
return {
|
||||
"text/html": "<b>Bold HTML</b>",
|
||||
"image/png": "<base64-encoded-png-data>",
|
||||
}
|
||||
```
|
||||
|
||||
Heavily inspired by
|
||||
[IPython's rich display system](https://ipython.readthedocs.io/en/stable/api/generated/IPython.display.html).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import html
|
||||
import io
|
||||
import re
|
||||
|
||||
from pyscript.magic_js import current_target, document, window
|
||||
from collections import OrderedDict
|
||||
from pyscript.context import current_target, document, window
|
||||
from pyscript.ffi import is_none
|
||||
|
||||
_MIME_METHODS = {
|
||||
"savefig": "image/png",
|
||||
"_repr_javascript_": "application/javascript",
|
||||
"_repr_json_": "application/json",
|
||||
"_repr_latex": "text/latex",
|
||||
"_repr_png_": "image/png",
|
||||
"_repr_jpeg_": "image/jpeg",
|
||||
"_repr_pdf_": "application/pdf",
|
||||
"_repr_svg_": "image/svg+xml",
|
||||
"_repr_markdown_": "text/markdown",
|
||||
"_repr_html_": "text/html",
|
||||
"__repr__": "text/plain",
|
||||
}
|
||||
|
||||
|
||||
def _render_image(mime, value, meta):
|
||||
# If the image value is using bytes we should convert it to base64
|
||||
# otherwise it will return raw bytes and the browser will not be able to
|
||||
# render it.
|
||||
"""
|
||||
Render image (`mime`) data (`value`) as an HTML img element with data URL.
|
||||
Any `meta` attributes are added to the img tag.
|
||||
|
||||
Accepts both raw bytes and base64-encoded strings for flexibility. This
|
||||
only handles PNG and JPEG images. SVG images are handled separately as
|
||||
their raw XML content (which the browser can render directly).
|
||||
"""
|
||||
if isinstance(value, bytes):
|
||||
value = base64.b64encode(value).decode("utf-8")
|
||||
|
||||
# This is the pattern of base64 strings
|
||||
base64_pattern = re.compile(
|
||||
r"^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$"
|
||||
)
|
||||
# If value doesn't match the base64 pattern we should encode it to base64
|
||||
if len(value) > 0 and not base64_pattern.match(value):
|
||||
value = base64.b64encode(value.encode("utf-8")).decode("utf-8")
|
||||
|
||||
data = f"data:{mime};charset=utf-8;base64,{value}"
|
||||
attrs = " ".join(['{k}="{v}"' for k, v in meta.items()])
|
||||
return f'<img src="{data}" {attrs}></img>'
|
||||
attrs = "".join([f' {k}="{v}"' for k, v in meta.items()])
|
||||
return f'<img src="data:{mime};base64,{value}"{attrs}>'
|
||||
|
||||
|
||||
def _identity(value, meta):
|
||||
return value
|
||||
|
||||
|
||||
_MIME_RENDERERS = {
|
||||
"text/plain": html.escape,
|
||||
"text/html": _identity,
|
||||
"image/png": lambda value, meta: _render_image("image/png", value, meta),
|
||||
"image/jpeg": lambda value, meta: _render_image("image/jpeg", value, meta),
|
||||
"image/svg+xml": _identity,
|
||||
"application/json": _identity,
|
||||
"application/javascript": lambda value, meta: f"<script>{value}<\\/script>",
|
||||
# Maps MIME types to rendering functions.
|
||||
_MIME_TO_RENDERERS = {
|
||||
"text/plain": lambda v, m: html.escape(v),
|
||||
"text/html": lambda v, m: v,
|
||||
"image/png": lambda v, m: _render_image("image/png", v, m),
|
||||
"image/jpeg": lambda v, m: _render_image("image/jpeg", v, m),
|
||||
"image/svg+xml": lambda v, m: v,
|
||||
"application/json": lambda v, m: v,
|
||||
"application/javascript": lambda v, m: f"<script>{v}<\\/script>",
|
||||
}
|
||||
|
||||
|
||||
# Maps Python representation methods to MIME types. This is an ordered dict
|
||||
# because the order defines preference when multiple methods are available,
|
||||
# and MicroPython's limited dicts don't preserve insertion order.
|
||||
_METHOD_TO_MIME = OrderedDict(
|
||||
[
|
||||
("savefig", "image/png"),
|
||||
("_repr_png_", "image/png"),
|
||||
("_repr_jpeg_", "image/jpeg"),
|
||||
("_repr_svg_", "image/svg+xml"),
|
||||
("_repr_html_", "text/html"),
|
||||
("_repr_json_", "application/json"),
|
||||
("_repr_javascript_", "application/javascript"),
|
||||
("__repr__", "text/plain"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class HTML:
|
||||
"""
|
||||
Wrap a string so that display() can render it as plain HTML
|
||||
Wrap a string to render as unescaped HTML in `display()`. This is
|
||||
necessary because plain strings are automatically HTML-escaped for safety:
|
||||
|
||||
```python
|
||||
from pyscript import HTML, display
|
||||
|
||||
|
||||
display(HTML("<h1>Hello World</h1>"))
|
||||
```
|
||||
|
||||
Inspired by
|
||||
[`IPython.display.HTML`](https://ipython.readthedocs.io/en/stable/api/generated/IPython.display.html#IPython.display.HTML).
|
||||
"""
|
||||
|
||||
def __init__(self, html):
|
||||
@@ -68,112 +108,156 @@ class HTML:
|
||||
return self._html
|
||||
|
||||
|
||||
def _eval_formatter(obj, print_method):
|
||||
def _get_representation(obj, method):
|
||||
"""
|
||||
Evaluates a formatter method.
|
||||
Call the given representation `method` on an object (`obj`).
|
||||
|
||||
Handles special cases like matplotlib's `savefig`. Returns `None`
|
||||
if the `method` doesn't exist.
|
||||
"""
|
||||
if print_method == "__repr__":
|
||||
if method == "__repr__":
|
||||
return repr(obj)
|
||||
if hasattr(obj, print_method):
|
||||
if print_method == "savefig":
|
||||
if not hasattr(obj, method):
|
||||
return None
|
||||
if method == "savefig":
|
||||
buf = io.BytesIO()
|
||||
obj.savefig(buf, format="png")
|
||||
buf.seek(0)
|
||||
return base64.b64encode(buf.read()).decode("utf-8")
|
||||
return getattr(obj, print_method)()
|
||||
if print_method == "_repr_mimebundle_":
|
||||
return {}, {}
|
||||
return None
|
||||
return getattr(obj, method)()
|
||||
|
||||
|
||||
def _format_mime(obj):
|
||||
def _get_content_and_mime(obj):
|
||||
"""
|
||||
Formats object using _repr_x_ methods.
|
||||
Returns the formatted raw content to be inserted into the DOM representing
|
||||
the given object, along with the object's detected MIME type.
|
||||
|
||||
Returns a tuple of (html_string, mime_type).
|
||||
|
||||
Prefers _repr_mimebundle_ if available, otherwise tries individual
|
||||
representation methods, falling back to __repr__ (with a warning in
|
||||
the console).
|
||||
|
||||
Implements a subset of IPython's rich display system (mimebundle support,
|
||||
etc...).
|
||||
"""
|
||||
if isinstance(obj, str):
|
||||
return html.escape(obj), "text/plain"
|
||||
|
||||
mimebundle = _eval_formatter(obj, "_repr_mimebundle_")
|
||||
# Prefer an object's mimebundle.
|
||||
mimebundle = _get_representation(obj, "_repr_mimebundle_")
|
||||
if mimebundle:
|
||||
if isinstance(mimebundle, tuple):
|
||||
format_dict, _ = mimebundle
|
||||
# Grab global metadata.
|
||||
format_dict, global_meta = mimebundle
|
||||
else:
|
||||
format_dict = mimebundle
|
||||
|
||||
output, not_available = None, []
|
||||
for method, mime_type in _MIME_METHODS.items():
|
||||
if mime_type in format_dict:
|
||||
output = format_dict[mime_type]
|
||||
else:
|
||||
output = _eval_formatter(obj, method)
|
||||
|
||||
if is_none(output):
|
||||
format_dict, global_meta = mimebundle, {}
|
||||
# Try to render using mimebundle formats.
|
||||
for mime_type, output in format_dict.items():
|
||||
if mime_type in _MIME_TO_RENDERERS:
|
||||
meta = global_meta.get(mime_type, {})
|
||||
# If output is a tuple, merge format-specific metadata.
|
||||
if isinstance(output, tuple):
|
||||
output, format_meta = output
|
||||
meta.update(format_meta)
|
||||
return _MIME_TO_RENDERERS[mime_type](output, meta), mime_type
|
||||
# No mimebundle or no available renderers therein, so try individual
|
||||
# methods.
|
||||
for method, mime_type in _METHOD_TO_MIME.items():
|
||||
if mime_type not in _MIME_TO_RENDERERS:
|
||||
continue
|
||||
if mime_type not in _MIME_RENDERERS:
|
||||
not_available.append(mime_type)
|
||||
output = _get_representation(obj, method)
|
||||
if output is None:
|
||||
continue
|
||||
break
|
||||
if is_none(output):
|
||||
if not_available:
|
||||
window.console.warn(
|
||||
f"Rendered object requested unavailable MIME renderers: {not_available}"
|
||||
)
|
||||
output = repr(output)
|
||||
mime_type = "text/plain"
|
||||
elif isinstance(output, tuple):
|
||||
output, meta = output
|
||||
else:
|
||||
meta = {}
|
||||
return _MIME_RENDERERS[mime_type](output, meta), mime_type
|
||||
if isinstance(output, tuple):
|
||||
output, meta = output
|
||||
return _MIME_TO_RENDERERS[mime_type](output, meta), mime_type
|
||||
# Ultimate fallback to repr with warning.
|
||||
window.console.warn(
|
||||
f"Object {type(obj).__name__} has no supported representation method. "
|
||||
"Using __repr__ as fallback."
|
||||
)
|
||||
output = repr(obj)
|
||||
return html.escape(output), "text/plain"
|
||||
|
||||
|
||||
def _write(element, value, append=False):
|
||||
html, mime_type = _format_mime(value)
|
||||
if html == "\\n":
|
||||
def _write_to_dom(element, value, append):
|
||||
"""
|
||||
Given an `element` and a `value`, write formatted content to the referenced
|
||||
DOM element. If `append` is True, content is added to the existing content;
|
||||
otherwise, the existing content is replaced.
|
||||
|
||||
Creates a wrapper `div` when appending multiple items to preserve
|
||||
structure.
|
||||
"""
|
||||
html_content, mime_type = _get_content_and_mime(value)
|
||||
if not html_content.strip():
|
||||
return
|
||||
|
||||
if append:
|
||||
out_element = document.createElement("div")
|
||||
element.append(out_element)
|
||||
container = document.createElement("div")
|
||||
element.append(container)
|
||||
else:
|
||||
out_element = element.lastElementChild
|
||||
if is_none(out_element):
|
||||
out_element = element
|
||||
|
||||
container = element
|
||||
if mime_type in ("application/javascript", "text/html"):
|
||||
script_element = document.createRange().createContextualFragment(html)
|
||||
out_element.append(script_element)
|
||||
container.append(document.createRange().createContextualFragment(html_content))
|
||||
else:
|
||||
out_element.innerHTML = html
|
||||
container.innerHTML = html_content
|
||||
|
||||
|
||||
def display(*values, target=None, append=True):
|
||||
if is_none(target):
|
||||
"""
|
||||
Display Python objects in the web page.
|
||||
|
||||
* `*values`: Python objects to display. Each object is introspected to
|
||||
determine the appropriate rendering method.
|
||||
* `target`: DOM element ID where content should be displayed. If `None`
|
||||
(default), uses the current script tag's designated output area. This
|
||||
can start with '#' (which will be stripped for compatibility).
|
||||
* `append`: If `True` (default), add content to existing output. If
|
||||
`False`, replace existing content before displaying.
|
||||
|
||||
When used in a worker, `display()` requires an explicit `target` parameter
|
||||
to identify where content will be displayed. If used on the main thread,
|
||||
it automatically uses the current `<script>` tag as the target. If the
|
||||
script tag has a `target` attribute, that element will be used instead.
|
||||
|
||||
A ValueError is raised if a valid target cannot be found for the current
|
||||
context.
|
||||
|
||||
```python
|
||||
from pyscript import display, HTML
|
||||
|
||||
|
||||
# Display raw HTML.
|
||||
display(HTML("<h1>Hello, World!</h1>"))
|
||||
|
||||
# Display in current script's output area.
|
||||
display("Hello, World!")
|
||||
|
||||
# Display in a specific element.
|
||||
display("Hello", target="my-div")
|
||||
|
||||
# Replace existing content (note the `#`).
|
||||
display("New content", target="#my-div", append=False)
|
||||
|
||||
# Display multiple values in the default target.
|
||||
display("First", "Second", "Third")
|
||||
```
|
||||
"""
|
||||
if isinstance(target, str):
|
||||
# There's a valid target.
|
||||
target = target[1:] if target.startswith("#") else target
|
||||
elif is_none(target):
|
||||
target = current_target()
|
||||
elif not isinstance(target, str):
|
||||
msg = f"target must be str or None, not {target.__class__.__name__}"
|
||||
raise TypeError(msg)
|
||||
elif target == "":
|
||||
msg = "Cannot have an empty target"
|
||||
raise ValueError(msg)
|
||||
elif target.startswith("#"):
|
||||
# note: here target is str and not None!
|
||||
# align with @when behavior
|
||||
target = target[1:]
|
||||
|
||||
element = document.getElementById(target)
|
||||
|
||||
# If target cannot be found on the page, a ValueError is raised
|
||||
if is_none(element):
|
||||
msg = f"Invalid selector with id={target}. Cannot be found in the page."
|
||||
raise ValueError(msg)
|
||||
|
||||
# if element is a <script type="py">, it has a 'target' attribute which
|
||||
# points to the visual element holding the displayed values. In that case,
|
||||
# use that.
|
||||
raise ValueError(f"Cannot find element with id='{target}' in the page.")
|
||||
# If possible, use a script tag's target attribute.
|
||||
if element.tagName == "SCRIPT" and hasattr(element, "target"):
|
||||
element = element.target
|
||||
|
||||
for v in values:
|
||||
# Clear before displaying all values when not appending.
|
||||
if not append:
|
||||
element.replaceChildren()
|
||||
_write(element, v, append=append)
|
||||
# Add each value.
|
||||
for value in values:
|
||||
_write_to_dom(element, value, append)
|
||||
|
||||
@@ -1,17 +1,47 @@
|
||||
"""
|
||||
Event handling for PyScript.
|
||||
|
||||
This module provides two complementary systems:
|
||||
|
||||
1. The `Event` class: A simple publish-subscribe pattern for custom events
|
||||
within *your* Python code.
|
||||
|
||||
2. The `@when` decorator: Connects Python functions to browser DOM events,
|
||||
or instances of the `Event` class, allowing you to respond to user
|
||||
interactions like clicks, key presses and form submissions, or to custom
|
||||
events defined in your Python code.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import sys
|
||||
|
||||
from functools import wraps
|
||||
from pyscript.magic_js import document
|
||||
from pyscript.ffi import create_proxy
|
||||
from pyscript.context import document
|
||||
from pyscript.ffi import create_proxy, to_js
|
||||
from pyscript.util import is_awaitable
|
||||
from pyscript import config
|
||||
|
||||
|
||||
class Event:
|
||||
"""
|
||||
Represents something that may happen at some point in the future.
|
||||
A custom event that can notify multiple listeners when triggered.
|
||||
|
||||
Use this class to create your own event system within Python code.
|
||||
Listeners can be either regular functions or async functions.
|
||||
|
||||
```python
|
||||
from pyscript.events import Event
|
||||
|
||||
# Create a custom event.
|
||||
data_loaded = Event()
|
||||
|
||||
# Add a listener.
|
||||
def on_data_loaded(result):
|
||||
print(f"Data loaded: {result}")
|
||||
|
||||
data_loaded.add_listener(on_data_loaded)
|
||||
|
||||
# Time passes.... trigger the event.
|
||||
data_loaded.trigger({"data": 123})
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
@@ -19,116 +49,189 @@ class Event:
|
||||
|
||||
def trigger(self, result):
|
||||
"""
|
||||
Trigger the event with a result to pass into the handlers.
|
||||
Trigger the event and notify all listeners with the given `result`.
|
||||
"""
|
||||
for listener in self._listeners:
|
||||
if is_awaitable(listener):
|
||||
# Use create task to avoid making this an async function.
|
||||
asyncio.create_task(listener(result))
|
||||
else:
|
||||
listener(result)
|
||||
|
||||
def add_listener(self, listener):
|
||||
"""
|
||||
Add a callable/awaitable to listen to when this event is triggered.
|
||||
Add a function to be called when this event is triggered.
|
||||
|
||||
The `listener` must be callable. It can be either a regular function
|
||||
or an async function. Duplicate listeners are ignored.
|
||||
"""
|
||||
if is_awaitable(listener) or callable(listener):
|
||||
if not callable(listener):
|
||||
msg = "Listener must be callable."
|
||||
raise ValueError(msg)
|
||||
if listener not in self._listeners:
|
||||
self._listeners.append(listener)
|
||||
else:
|
||||
msg = "Listener must be callable or awaitable."
|
||||
raise ValueError(msg)
|
||||
|
||||
def remove_listener(self, *args):
|
||||
def remove_listener(self, *listeners):
|
||||
"""
|
||||
Clear the specified handler functions in *args. If no handlers
|
||||
provided, clear all handlers.
|
||||
Remove specified `listeners`. If none specified, remove all listeners.
|
||||
"""
|
||||
if args:
|
||||
for listener in args:
|
||||
if listeners:
|
||||
for listener in listeners:
|
||||
try:
|
||||
self._listeners.remove(listener)
|
||||
except ValueError:
|
||||
pass # Silently ignore listeners not in the list.
|
||||
else:
|
||||
self._listeners = []
|
||||
|
||||
|
||||
def when(target, *args, **kwargs):
|
||||
def when(event_type, selector=None, **options):
|
||||
"""
|
||||
Add an event listener to the target element(s) for the specified event type.
|
||||
A decorator to handle DOM events or custom `Event` objects.
|
||||
|
||||
The target can be a string representing the event type, or an Event object.
|
||||
If the target is an Event object, the event listener will be added to that
|
||||
object. If the target is a string, the event listener will be added to the
|
||||
element(s) that match the (second) selector argument.
|
||||
For DOM events, specify the `event_type` (e.g. `"click"`) and a `selector`
|
||||
for target elements. For custom `Event` objects, just pass the `Event`
|
||||
instance as the `event_type`. It's also possible to pass a list of `Event`
|
||||
objects. The `selector` is required only for DOM events. It should be a
|
||||
[CSS selector string](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Selectors),
|
||||
`Element`, `ElementCollection`, or list of DOM elements.
|
||||
|
||||
If a (third) handler argument is provided, it will be called when the event
|
||||
is triggered; thus allowing this to be used as both a function and a
|
||||
decorator.
|
||||
For DOM events only, you can specify optional
|
||||
[addEventListener options](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#options):
|
||||
`capture`, `once`, `passive`, or `signal`.
|
||||
|
||||
The decorated function can be either a regular function or an async
|
||||
function. If the function accepts an argument, it will receive the event
|
||||
object (for DOM events) or the Event's result (for custom events). A
|
||||
function does not need to accept any arguments if it doesn't require them.
|
||||
|
||||
```python
|
||||
from pyscript import when, display
|
||||
|
||||
# Handle DOM events.
|
||||
@when("click", "#my-button")
|
||||
def handle_click(event):
|
||||
display("Button clicked!")
|
||||
|
||||
# Handle DOM events with options.
|
||||
@when("click", "#my-button", once=True)
|
||||
def handle_click_once(event):
|
||||
display("Button clicked once!")
|
||||
|
||||
# Handle custom events.
|
||||
my_event = Event()
|
||||
|
||||
@when(my_event)
|
||||
def handle_custom(): # No event argument needed.
|
||||
display("Custom event triggered!")
|
||||
|
||||
# Handle multiple custom events.
|
||||
another_event = Event()
|
||||
|
||||
def another_handler():
|
||||
display("Another custom event handler.")
|
||||
|
||||
# Attach the same handler to multiple events but not as a decorator.
|
||||
when([my_event, another_event])(another_handler)
|
||||
|
||||
# Trigger an Event instance from a DOM event via @when.
|
||||
@when("click", "#my-button")
|
||||
def handle_click(event):
|
||||
another_event.trigger("Button clicked!")
|
||||
|
||||
# Stacked decorators also work.
|
||||
@when("mouseover", "#my-div")
|
||||
@when(my_event)
|
||||
def handle_both(event):
|
||||
display("Either mouseover or custom event triggered!")
|
||||
```
|
||||
"""
|
||||
# If "when" is called as a function, try to grab the handler from the
|
||||
# arguments. If there's no handler, this must be a decorator based call.
|
||||
handler = None
|
||||
if args and (callable(args[0]) or is_awaitable(args[0])):
|
||||
handler = args[0]
|
||||
elif callable(kwargs.get("handler")) or is_awaitable(kwargs.get("handler")):
|
||||
handler = kwargs.pop("handler")
|
||||
# If the target is a string, it is the "older" use of `when` where it
|
||||
# represents the name of a DOM event.
|
||||
if isinstance(target, str):
|
||||
# Extract the selector from the arguments or keyword arguments.
|
||||
selector = args[0] if args else kwargs.pop("selector")
|
||||
if isinstance(event_type, str):
|
||||
# This is a DOM event to handle, so check and use the selector.
|
||||
if not selector:
|
||||
msg = "No selector provided."
|
||||
raise ValueError(msg)
|
||||
# Grab the DOM elements to which the target event will be attached.
|
||||
raise ValueError("Selector required for DOM event handling.")
|
||||
elements = _get_elements(selector)
|
||||
if not elements:
|
||||
raise ValueError(f"No elements found for selector: {selector}")
|
||||
|
||||
def decorator(func):
|
||||
wrapper = _create_wrapper(func)
|
||||
if isinstance(event_type, Event):
|
||||
# Custom Event - add listener.
|
||||
event_type.add_listener(wrapper)
|
||||
elif isinstance(event_type, list) and all(
|
||||
isinstance(t, Event) for t in event_type
|
||||
):
|
||||
# List of custom Events - add listener to each.
|
||||
for event in event_type:
|
||||
event.add_listener(wrapper)
|
||||
else:
|
||||
# DOM event - attach to all matched elements.
|
||||
for element in elements:
|
||||
element.addEventListener(
|
||||
event_type,
|
||||
create_proxy(wrapper),
|
||||
to_js(options) if options else False,
|
||||
)
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _get_elements(selector):
|
||||
"""
|
||||
Convert various `selector` types into a list of DOM elements.
|
||||
"""
|
||||
from pyscript.web import Element, ElementCollection
|
||||
|
||||
if isinstance(selector, str):
|
||||
elements = document.querySelectorAll(selector)
|
||||
return list(document.querySelectorAll(selector))
|
||||
elif isinstance(selector, Element):
|
||||
elements = [selector._dom_element]
|
||||
return [selector._dom_element]
|
||||
elif isinstance(selector, ElementCollection):
|
||||
elements = [el._dom_element for el in selector]
|
||||
return [el._dom_element for el in selector]
|
||||
elif isinstance(selector, list):
|
||||
return selector
|
||||
else:
|
||||
elements = selector if isinstance(selector, list) else [selector]
|
||||
return [selector]
|
||||
|
||||
def decorator(func):
|
||||
sig = inspect.signature(func)
|
||||
if sig.parameters:
|
||||
|
||||
def _create_wrapper(func):
|
||||
"""
|
||||
Create an appropriate wrapper for the given function, `func`.
|
||||
|
||||
The wrapper handles both sync and async functions, and respects whether
|
||||
the function expects to receive event arguments.
|
||||
"""
|
||||
# Get the original function if it's been wrapped. This avoids wrapper
|
||||
# loops when stacking decorators.
|
||||
original_func = func
|
||||
while hasattr(original_func, "__wrapped__"):
|
||||
original_func = original_func.__wrapped__
|
||||
# Inspect the original function signature.
|
||||
sig = inspect.signature(original_func)
|
||||
accepts_args = bool(sig.parameters)
|
||||
if is_awaitable(func):
|
||||
if accepts_args:
|
||||
|
||||
async def wrapper(event):
|
||||
return await func(event)
|
||||
|
||||
else:
|
||||
wrapper = func
|
||||
else:
|
||||
# Function doesn't receive events.
|
||||
if is_awaitable(func):
|
||||
|
||||
async def wrapper(*args, **kwargs):
|
||||
return await func()
|
||||
|
||||
else:
|
||||
if accepts_args:
|
||||
# Always create a new wrapper function to avoid issues with
|
||||
# stacked decorators getting into an infinite loop.
|
||||
|
||||
def wrapper(event):
|
||||
return func(event)
|
||||
|
||||
else:
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
return func()
|
||||
|
||||
wrapper = wraps(func)(wrapper)
|
||||
if isinstance(target, Event):
|
||||
# The target is a single Event object.
|
||||
target.add_listener(wrapper)
|
||||
elif isinstance(target, list) and all(isinstance(t, Event) for t in target):
|
||||
# The target is a list of Event objects.
|
||||
for evt in target:
|
||||
evt.add_listener(wrapper)
|
||||
else:
|
||||
# The target is a string representing an event type, and so a
|
||||
# DOM element or collection of elements is found in "elements".
|
||||
for el in elements:
|
||||
el.addEventListener(target, create_proxy(wrapper))
|
||||
return wrapper
|
||||
|
||||
# If "when" was called as a decorator, return the decorator function,
|
||||
# otherwise just call the internal decorator function with the supplied
|
||||
# handler.
|
||||
return decorator(handler) if handler else decorator
|
||||
return wraps(func)(wrapper)
|
||||
|
||||
@@ -1,87 +1,218 @@
|
||||
import json
|
||||
"""
|
||||
This module provides a Python-friendly interface to the
|
||||
[browser's fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API),
|
||||
returning native Python data types and supporting directly awaiting the promise
|
||||
and chaining method calls directly on the promise.
|
||||
|
||||
```python
|
||||
from pyscript.fetch import fetch
|
||||
url = "https://api.example.com/data"
|
||||
|
||||
# Pattern 1: Await the response, then extract data.
|
||||
response = await fetch(url)
|
||||
if response.ok:
|
||||
data = await response.json()
|
||||
else:
|
||||
raise NetworkError(f"Fetch failed: {response.status}")
|
||||
|
||||
# Pattern 2: Chain method calls directly on the promise.
|
||||
data = await fetch(url).json()
|
||||
```
|
||||
"""
|
||||
|
||||
import json
|
||||
import js
|
||||
from pyscript.util import as_bytearray
|
||||
|
||||
|
||||
### wrap the response to grant Pythonic results
|
||||
class _Response:
|
||||
class _FetchResponse:
|
||||
"""
|
||||
Wraps a JavaScript Response object with Pythonic data extraction methods.
|
||||
|
||||
This wrapper ensures that data returned from fetch is, if possible, in
|
||||
native Python types rather than JavaScript types.
|
||||
"""
|
||||
|
||||
def __init__(self, response):
|
||||
self._response = response
|
||||
|
||||
# grant access to response.ok and other fields
|
||||
def __getattr__(self, attr):
|
||||
"""
|
||||
Provide access to underlying Response properties like ok, status, etc.
|
||||
"""
|
||||
return getattr(self._response, attr)
|
||||
|
||||
# exposed methods with Pythonic results
|
||||
async def arrayBuffer(self):
|
||||
"""
|
||||
Get response body as a buffer (memoryview or bytes).
|
||||
|
||||
Returns a memoryview in MicroPython or bytes in Pyodide, representing
|
||||
the raw binary data.
|
||||
"""
|
||||
buffer = await self._response.arrayBuffer()
|
||||
# works in Pyodide
|
||||
if hasattr(buffer, "to_py"):
|
||||
# Pyodide conversion.
|
||||
return buffer.to_py()
|
||||
# shims in MicroPython
|
||||
# MicroPython conversion.
|
||||
return memoryview(as_bytearray(buffer))
|
||||
|
||||
async def blob(self):
|
||||
"""
|
||||
Get response body as a JavaScript Blob object.
|
||||
|
||||
Returns the raw JS Blob for use with other JS APIs.
|
||||
"""
|
||||
return await self._response.blob()
|
||||
|
||||
async def bytearray(self):
|
||||
"""
|
||||
Get response body as a Python bytearray.
|
||||
|
||||
Returns a mutable bytearray containing the response data.
|
||||
"""
|
||||
buffer = await self._response.arrayBuffer()
|
||||
return as_bytearray(buffer)
|
||||
|
||||
async def json(self):
|
||||
"""
|
||||
Parse response body as JSON and return Python objects.
|
||||
|
||||
Returns native Python dicts, lists, strings, numbers, etc.
|
||||
"""
|
||||
return json.loads(await self.text())
|
||||
|
||||
async def text(self):
|
||||
"""
|
||||
Get response body as a text string.
|
||||
"""
|
||||
return await self._response.text()
|
||||
|
||||
|
||||
### allow direct await to _Response methods
|
||||
class _DirectResponse:
|
||||
@staticmethod
|
||||
def setup(promise, response):
|
||||
promise._response = _Response(response)
|
||||
return promise._response
|
||||
class _FetchPromise:
|
||||
"""
|
||||
Wraps the fetch promise to enable direct method chaining.
|
||||
|
||||
This allows calling response methods directly on the fetch promise:
|
||||
`await fetch(url).json()` instead of requiring two separate awaits.
|
||||
|
||||
This feels more Pythonic since it matches typical usage patterns
|
||||
Python developers have got used to via libraries like `requests`.
|
||||
"""
|
||||
|
||||
def __init__(self, promise):
|
||||
self._promise = promise
|
||||
# To be resolved in the future via the setup() static method.
|
||||
promise._response = None
|
||||
# Add convenience methods directly to the promise.
|
||||
promise.arrayBuffer = self.arrayBuffer
|
||||
promise.blob = self.blob
|
||||
promise.bytearray = self.bytearray
|
||||
promise.json = self.json
|
||||
promise.text = self.text
|
||||
|
||||
async def _response(self):
|
||||
@staticmethod
|
||||
def setup(promise, response):
|
||||
"""
|
||||
Store the resolved response on the promise for later access.
|
||||
"""
|
||||
promise._response = _FetchResponse(response)
|
||||
return promise._response
|
||||
|
||||
async def _get_response(self):
|
||||
"""
|
||||
Get the cached response, or await the promise if not yet resolved.
|
||||
"""
|
||||
if not self._promise._response:
|
||||
await self._promise
|
||||
return self._promise._response
|
||||
|
||||
async def arrayBuffer(self):
|
||||
response = await self._response()
|
||||
response = await self._get_response()
|
||||
return await response.arrayBuffer()
|
||||
|
||||
async def blob(self):
|
||||
response = await self._response()
|
||||
response = await self._get_response()
|
||||
return await response.blob()
|
||||
|
||||
async def bytearray(self):
|
||||
response = await self._response()
|
||||
response = await self._get_response()
|
||||
return await response.bytearray()
|
||||
|
||||
async def json(self):
|
||||
response = await self._response()
|
||||
response = await self._get_response()
|
||||
return await response.json()
|
||||
|
||||
async def text(self):
|
||||
response = await self._response()
|
||||
response = await self._get_response()
|
||||
return await response.text()
|
||||
|
||||
|
||||
def fetch(url, **kw):
|
||||
# workaround Pyodide / MicroPython dict <-> js conversion
|
||||
options = js.JSON.parse(json.dumps(kw))
|
||||
awaited = lambda response, *args: _DirectResponse.setup(promise, response)
|
||||
promise = js.fetch(url, options).then(awaited)
|
||||
_DirectResponse(promise)
|
||||
def fetch(url, **options):
|
||||
"""
|
||||
Fetch a resource from the network using a Pythonic interface.
|
||||
|
||||
This wraps JavaScript's fetch API, returning Python-native data types
|
||||
and supporting both direct promise awaiting and method chaining.
|
||||
|
||||
The function takes a `url` and optional fetch `options` as keyword
|
||||
arguments. The `options` correspond to the JavaScript fetch API's
|
||||
[RequestInit dictionary](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit),
|
||||
and commonly include:
|
||||
|
||||
- `method`: HTTP method (e.g., `"GET"`, `"POST"`, `"PUT"` etc.)
|
||||
- `headers`: Dict of request headers.
|
||||
- `body`: Request body (string, dict for JSON, etc.)
|
||||
|
||||
The function returns a promise that resolves to a Response-like object
|
||||
with Pythonic methods to extract data:
|
||||
|
||||
- `await response.json()` to get JSON as Python objects.
|
||||
- `await response.text()` to get text data.
|
||||
- `await response.bytearray()` to get raw data as a bytearray.
|
||||
- `await response.arrayBuffer()` to get raw data as a memoryview or bytes.
|
||||
- `await response.blob()` to get the raw JS Blob object.
|
||||
|
||||
It's also possible to chain these methods directly on the fetch promise:
|
||||
`data = await fetch(url).json()`
|
||||
|
||||
The returned response object also exposes standard properties like
|
||||
`ok`, `status`, and `statusText` for checking response status.
|
||||
|
||||
```python
|
||||
# Simple GET request.
|
||||
response = await fetch("https://api.example.com/data")
|
||||
data = await response.json()
|
||||
|
||||
# Method chaining.
|
||||
data = await fetch("https://api.example.com/data").json()
|
||||
|
||||
# POST request with JSON.
|
||||
response = await fetch(
|
||||
"https://api.example.com/users",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({"name": "Alice"})
|
||||
)
|
||||
result = await response.json()
|
||||
|
||||
# Check response status codes.
|
||||
response = await fetch("https://api.example.com/data")
|
||||
if response.ok:
|
||||
# Status in the range 200-299.
|
||||
data = await response.json()
|
||||
elif response.status == 404:
|
||||
print("Resource not found")
|
||||
else:
|
||||
print(f"Error: {response.status} {response.statusText}")
|
||||
```
|
||||
"""
|
||||
# Convert Python dict to JavaScript object.
|
||||
js_options = js.JSON.parse(json.dumps(options))
|
||||
|
||||
# Setup response handler to wrap the result.
|
||||
def on_response(response, *_):
|
||||
return _FetchPromise.setup(promise, response)
|
||||
|
||||
promise = js.fetch(url, js_options).then(on_response)
|
||||
_FetchPromise(promise)
|
||||
return promise
|
||||
|
||||
@@ -1,48 +1,164 @@
|
||||
"""
|
||||
This module provides a unified
|
||||
[Foreign Function Interface (FFI)](https://en.wikipedia.org/wiki/Foreign_function_interface)
|
||||
layer for Python/JavaScript interactions, that works consistently across both
|
||||
Pyodide and MicroPython, and in a worker or main thread context, abstracting
|
||||
away the differences in their JavaScript interop APIs.
|
||||
|
||||
The following utilities work on both the main thread and in worker contexts:
|
||||
|
||||
- `create_proxy`: Create a persistent JavaScript proxy of a Python function.
|
||||
- `to_js`: Convert Python objects to JavaScript objects.
|
||||
- `is_none`: Check if a value is Python `None` or JavaScript `null`.
|
||||
- `assign`: Merge objects (like JavaScript's `Object.assign`).
|
||||
|
||||
The following utilities are specific to worker contexts:
|
||||
|
||||
- `direct`: Mark objects for direct JavaScript access.
|
||||
- `gather`: Collect multiple values from worker contexts.
|
||||
- `query`: Query objects in worker contexts.
|
||||
|
||||
More details of the `direct`, `gather`, and `query` utilities
|
||||
[can be found here](https://github.com/WebReflection/reflected-ffi?tab=readme-ov-file#remote-extra-utilities).
|
||||
"""
|
||||
|
||||
try:
|
||||
# Attempt to import Pyodide's FFI utilities.
|
||||
import js
|
||||
from pyodide.ffi import create_proxy as _cp
|
||||
from pyodide.ffi import to_js as _py_tjs
|
||||
from pyodide.ffi import jsnull
|
||||
|
||||
from_entries = js.Object.fromEntries
|
||||
is_none = lambda value: value is None or value is jsnull
|
||||
|
||||
def _tjs(value, **kw):
|
||||
if not hasattr(kw, "dict_converter"):
|
||||
def _to_js_wrapper(value, **kw):
|
||||
if "dict_converter" not in kw:
|
||||
kw["dict_converter"] = from_entries
|
||||
return _py_tjs(value, **kw)
|
||||
|
||||
except:
|
||||
# Fallback to jsffi for MicroPython.
|
||||
from jsffi import create_proxy as _cp
|
||||
from jsffi import to_js as _tjs
|
||||
from jsffi import to_js as _to_js_wrapper
|
||||
import js
|
||||
|
||||
jsnull = js.Object.getPrototypeOf(js.Object.prototype)
|
||||
is_none = lambda value: value is None or value is jsnull
|
||||
|
||||
create_proxy = _cp
|
||||
to_js = _tjs
|
||||
|
||||
def create_proxy(func):
|
||||
"""
|
||||
Create a persistent JavaScript proxy of a Python function.
|
||||
|
||||
This proxy allows JavaScript code to call the Python function
|
||||
seamlessly, maintaining the correct context and argument handling.
|
||||
|
||||
This is especially useful when passing Python functions as callbacks
|
||||
to JavaScript APIs (without `create_proxy`, the function would be
|
||||
garbage collected after the declaration of the callback).
|
||||
|
||||
```python
|
||||
from pyscript import ffi
|
||||
from pyscript import document
|
||||
|
||||
my_button = document.getElementById("my-button")
|
||||
|
||||
def py_callback(x):
|
||||
print(f"Callback called with {x}")
|
||||
|
||||
my_button.addEventListener("click", ffi.create_proxy(py_callback))
|
||||
```
|
||||
"""
|
||||
return _cp(func)
|
||||
|
||||
|
||||
def to_js(value, **kw):
|
||||
"""
|
||||
Convert Python objects to JavaScript objects.
|
||||
|
||||
This ensures a Python `dict` becomes a
|
||||
[proper JavaScript object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object)
|
||||
rather a JavaScript [`Map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map),
|
||||
which is more intuitive for most use cases.
|
||||
|
||||
Where required, the underlying `to_js` uses `Object.fromEntries` for
|
||||
`dict` conversion.
|
||||
|
||||
```python
|
||||
from pyscript import ffi
|
||||
import js
|
||||
|
||||
|
||||
note = {
|
||||
"body": "This is a notification",
|
||||
"icon": "icon.png"
|
||||
}
|
||||
|
||||
js.Notification.new("Hello!", ffi.to_js(note))
|
||||
```
|
||||
"""
|
||||
return _to_js_wrapper(value, **kw)
|
||||
|
||||
|
||||
def is_none(value):
|
||||
"""
|
||||
Check if a value is `None` or JavaScript `null`.
|
||||
|
||||
In Pyodide, JavaScript `null` is represented by the `jsnull` object,
|
||||
so we check for both Python `None` and `jsnull`. This function ensures
|
||||
consistent behavior across Pyodide and MicroPython for null-like
|
||||
values.
|
||||
|
||||
```python
|
||||
from pyscript import ffi
|
||||
import js
|
||||
|
||||
|
||||
val1 = None
|
||||
val2 = js.null
|
||||
val3 = 42
|
||||
|
||||
print(ffi.is_none(val1)) # True
|
||||
print(ffi.is_none(val2)) # True
|
||||
print(ffi.is_none(val3)) # False
|
||||
```
|
||||
"""
|
||||
return value is None or value is jsnull
|
||||
|
||||
|
||||
try:
|
||||
# Worker context utilities from reflected-ffi.
|
||||
# See https://github.com/WebReflection/reflected-ffi for more details.
|
||||
from polyscript import ffi as _ffi
|
||||
|
||||
_assign = _ffi.assign
|
||||
|
||||
direct = _ffi.direct
|
||||
gather = _ffi.gather
|
||||
query = _ffi.query
|
||||
|
||||
def assign(source, *args):
|
||||
for arg in args:
|
||||
_ffi.assign(source, to_js(arg))
|
||||
return source
|
||||
|
||||
except:
|
||||
# Fallback implementations for main thread context.
|
||||
import js
|
||||
|
||||
_assign = js.Object.assign
|
||||
|
||||
direct = lambda source: source
|
||||
|
||||
def assign(source, *args):
|
||||
|
||||
def assign(source, *args):
|
||||
"""
|
||||
Merge JavaScript objects (like
|
||||
[Object.assign](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign)).
|
||||
|
||||
Takes a target object and merges properties from one or more source
|
||||
objects into it, returning the modified target.
|
||||
|
||||
```python
|
||||
obj = js.Object.new()
|
||||
ffi.assign(obj, {"a": 1}, {"b": 2})
|
||||
# obj now has properties a=1 and b=2
|
||||
```
|
||||
"""
|
||||
for arg in args:
|
||||
_assign(source, to_js(arg))
|
||||
return source
|
||||
|
||||
@@ -1,4 +1,36 @@
|
||||
# https://www.npmjs.com/package/flatted
|
||||
"""
|
||||
This module is a Python implementation of the
|
||||
[Flatted JavaScript library](https://www.npmjs.com/package/flatted), which
|
||||
provides a light and fast way to serialize and deserialize JSON structures
|
||||
that contain circular references.
|
||||
|
||||
Standard JSON cannot handle circular references - attempting to serialize an
|
||||
object that references itself will cause an error. Flatted solves this by
|
||||
transforming circular structures into a flat array format that can be safely
|
||||
serialized and later reconstructed.
|
||||
|
||||
Common use cases:
|
||||
|
||||
- Serializing complex object graphs with circular references.
|
||||
- Working with DOM-like structures that contain parent/child references.
|
||||
- Preserving object identity when serializing data structures.
|
||||
|
||||
```python
|
||||
from pyscript import flatted
|
||||
|
||||
|
||||
# Create a circular structure.
|
||||
obj = {"name": "parent"}
|
||||
obj["self"] = obj # Circular reference!
|
||||
|
||||
# Standard json.dumps would fail here.
|
||||
serialized = flatted.stringify(obj)
|
||||
|
||||
# Reconstruct the original structure.
|
||||
restored = flatted.parse(serialized)
|
||||
assert restored["self"] is restored # Circular reference preserved!
|
||||
```
|
||||
"""
|
||||
|
||||
import json as _json
|
||||
|
||||
@@ -114,6 +146,26 @@ def _wrap(value):
|
||||
|
||||
|
||||
def parse(value, *args, **kwargs):
|
||||
"""
|
||||
Parse a Flatted JSON string and reconstruct the original structure.
|
||||
|
||||
This function takes a `value` containing a JSON string created by
|
||||
Flatted's stringify() and reconstructs the original Python object,
|
||||
including any circular references. The `*args` and `**kwargs` are passed
|
||||
to json.loads() for additional customization.
|
||||
|
||||
```python
|
||||
from pyscript import flatted
|
||||
|
||||
|
||||
# Parse a Flatted JSON string.
|
||||
json_string = '[{"name": "1", "self": "0"}, "parent"]'
|
||||
obj = flatted.parse(json_string)
|
||||
|
||||
# Circular references are preserved.
|
||||
assert obj["self"] is obj
|
||||
```
|
||||
"""
|
||||
json = _json.loads(value, *args, **kwargs)
|
||||
wrapped = []
|
||||
for value in json:
|
||||
@@ -138,6 +190,31 @@ def parse(value, *args, **kwargs):
|
||||
|
||||
|
||||
def stringify(value, *args, **kwargs):
|
||||
"""
|
||||
Serialize a Python object to a Flatted JSON string.
|
||||
|
||||
This function converts `value`, a Python object (including those with
|
||||
circular references), into a JSON string that can be safely transmitted
|
||||
or stored. The resulting string can be reconstructed using Flatted's
|
||||
parse(). The `*args` and `**kwargs` are passed to json.dumps() for
|
||||
additional customization.
|
||||
|
||||
```python
|
||||
from pyscript import flatted
|
||||
|
||||
|
||||
# Create an object with a circular reference.
|
||||
parent = {"name": "parent", "children": []}
|
||||
child = {"name": "child", "parent": parent}
|
||||
parent["children"].append(child)
|
||||
|
||||
# Serialize it (standard json.dumps would fail here).
|
||||
json_string = flatted.stringify(parent)
|
||||
|
||||
# Can optionally pretty-print via JSON indentation etc.
|
||||
pretty = flatted.stringify(parent, indent=2)
|
||||
```
|
||||
"""
|
||||
known = _Known()
|
||||
input = []
|
||||
output = []
|
||||
|
||||
@@ -1,7 +1,63 @@
|
||||
"""
|
||||
This module provides an API for mounting directories from the user's local
|
||||
filesystem into the browser's virtual filesystem. This means Python code,
|
||||
running in the browser, can read and write files on the user's local machine.
|
||||
|
||||
!!! warning
|
||||
**This API only works in Chromium-based browsers** (Chrome, Edge,
|
||||
Vivaldi, Brave, etc.) that support the
|
||||
[File System Access API](https://wicg.github.io/file-system-access/).
|
||||
|
||||
The module maintains a `mounted` dictionary that tracks all currently mounted
|
||||
paths and their associated filesystem handles.
|
||||
|
||||
```python
|
||||
from pyscript import fs, document, when
|
||||
|
||||
|
||||
# Mount a local directory to the `/local` mount point in the browser's
|
||||
# virtual filesystem (may prompt user for permission).
|
||||
await fs.mount("/local")
|
||||
|
||||
# Alternatively, mount on a button click event. This is important because
|
||||
# if the call to `fs.mount` happens after a click or other transient event,
|
||||
# the confirmation dialog will not be shown.
|
||||
@when("click", "#mount-button")
|
||||
async def handler(event):
|
||||
await fs.mount("/another_dir")
|
||||
|
||||
# Work with files in the mounted directory as usual.
|
||||
with open("/local/example.txt", "w") as f:
|
||||
f.write("Hello from PyScript!")
|
||||
|
||||
# Ensure changes are written to local filesystem.
|
||||
await fs.sync("/local")
|
||||
|
||||
# Clean up when done.
|
||||
await fs.unmount("/local")
|
||||
```
|
||||
"""
|
||||
|
||||
import js
|
||||
from _pyscript import fs as _fs, interpreter
|
||||
from pyscript import window
|
||||
from pyscript.ffi import to_js
|
||||
from pyscript.context import RUNNING_IN_WORKER
|
||||
|
||||
# Worker-specific imports.
|
||||
if RUNNING_IN_WORKER:
|
||||
from pyscript.context import sync as sync_with_worker
|
||||
from polyscript import IDBMap
|
||||
|
||||
mounted = {}
|
||||
"""Global dictionary tracking mounted paths and their filesystem handles."""
|
||||
|
||||
|
||||
async def get_handler(details):
|
||||
async def _check_permission(details):
|
||||
"""
|
||||
Check if permission has been granted for a filesystem handler. Returns
|
||||
the handler if permission is granted, otherwise None.
|
||||
"""
|
||||
handler = details.handler
|
||||
options = details.options
|
||||
permission = await handler.queryPermission(options)
|
||||
@@ -9,94 +65,194 @@ async def get_handler(details):
|
||||
|
||||
|
||||
async def mount(path, mode="readwrite", root="", id="pyscript"):
|
||||
import js
|
||||
from _pyscript import fs, interpreter
|
||||
from pyscript.ffi import to_js
|
||||
from pyscript.magic_js import (
|
||||
RUNNING_IN_WORKER,
|
||||
sync,
|
||||
)
|
||||
"""
|
||||
Mount a directory from the local filesystem to the virtual filesystem
|
||||
at the specified `path` mount point. The `mode` can be "readwrite" or
|
||||
"read" to specify access level. The `root` parameter provides a hint
|
||||
for the file picker starting location. The `id` parameter allows multiple
|
||||
distinct mounts at the same path.
|
||||
|
||||
On first use, the browser will prompt the user to select a directory
|
||||
and grant permission.
|
||||
|
||||
```python
|
||||
from pyscript import fs
|
||||
|
||||
|
||||
# Basic mount with default settings.
|
||||
await fs.mount("/local")
|
||||
|
||||
# Mount with read-only access.
|
||||
await fs.mount("/readonly", mode="read")
|
||||
|
||||
# Mount with a hint to start in Downloads folder.
|
||||
await fs.mount("/downloads", root="downloads")
|
||||
|
||||
# Mount with a custom ID to track different directories.
|
||||
await fs.mount("/project", id="my-project")
|
||||
```
|
||||
|
||||
If called during a user interaction (like a button click), the
|
||||
permission dialog may be skipped if permission was previously granted.
|
||||
"""
|
||||
js.console.warn("experimental pyscript.fs ⚠️")
|
||||
|
||||
# Check if path is already mounted with a different ID.
|
||||
mount_key = f"{path}@{id}"
|
||||
if path in mounted:
|
||||
# Path already mounted - check if it's the same ID.
|
||||
for existing_key in mounted.keys():
|
||||
if existing_key.startswith(f"{path}@") and existing_key != mount_key:
|
||||
raise ValueError(
|
||||
f"Path '{path}' is already mounted with a different ID. "
|
||||
f"Unmount it first or use a different path."
|
||||
)
|
||||
|
||||
details = None
|
||||
handler = None
|
||||
|
||||
uid = f"{path}@{id}"
|
||||
|
||||
options = {"id": id, "mode": mode}
|
||||
if root != "":
|
||||
options["startIn"] = root
|
||||
|
||||
if RUNNING_IN_WORKER:
|
||||
fsh = sync.storeFSHandler(uid, to_js(options))
|
||||
fs_handler = sync_with_worker.storeFSHandler(mount_key, to_js(options))
|
||||
|
||||
# allow both async and/or SharedArrayBuffer use case
|
||||
if isinstance(fsh, bool):
|
||||
success = fsh
|
||||
# Handle both async and SharedArrayBuffer use cases.
|
||||
if isinstance(fs_handler, bool):
|
||||
success = fs_handler
|
||||
else:
|
||||
success = await fsh
|
||||
success = await fs_handler
|
||||
|
||||
if success:
|
||||
from polyscript import IDBMap
|
||||
from pyscript import window
|
||||
|
||||
idbm = IDBMap.new(fs.NAMESPACE)
|
||||
details = await idbm.get(uid)
|
||||
handler = await get_handler(details)
|
||||
idbm = IDBMap.new(_fs.NAMESPACE)
|
||||
details = await idbm.get(mount_key)
|
||||
handler = await _check_permission(details)
|
||||
if handler is None:
|
||||
# force await in either async or sync scenario
|
||||
await js.Promise.resolve(sync.getFSHandler(details.options))
|
||||
# Force await in either async or sync scenario.
|
||||
await js.Promise.resolve(sync_with_worker.getFSHandler(details.options))
|
||||
handler = details.handler
|
||||
else:
|
||||
raise RuntimeError(_fs.ERROR)
|
||||
|
||||
else:
|
||||
raise RuntimeError(fs.ERROR)
|
||||
|
||||
else:
|
||||
success = await fs.idb.has(uid)
|
||||
success = await _fs.idb.has(mount_key)
|
||||
|
||||
if success:
|
||||
details = await fs.idb.get(uid)
|
||||
handler = await get_handler(details)
|
||||
details = await _fs.idb.get(mount_key)
|
||||
handler = await _check_permission(details)
|
||||
if handler is None:
|
||||
handler = await fs.getFileSystemDirectoryHandle(details.options)
|
||||
handler = await _fs.getFileSystemDirectoryHandle(details.options)
|
||||
else:
|
||||
js_options = to_js(options)
|
||||
handler = await fs.getFileSystemDirectoryHandle(js_options)
|
||||
handler = await _fs.getFileSystemDirectoryHandle(js_options)
|
||||
details = {"handler": handler, "options": js_options}
|
||||
await fs.idb.set(uid, to_js(details))
|
||||
await _fs.idb.set(mount_key, to_js(details))
|
||||
|
||||
mounted[path] = await interpreter.mountNativeFS(path, handler)
|
||||
|
||||
|
||||
async def revoke(path, id="pyscript"):
|
||||
from _pyscript import fs, interpreter
|
||||
from pyscript.magic_js import (
|
||||
RUNNING_IN_WORKER,
|
||||
sync,
|
||||
)
|
||||
|
||||
uid = f"{path}@{id}"
|
||||
|
||||
if RUNNING_IN_WORKER:
|
||||
had = sync.deleteFSHandler(uid)
|
||||
else:
|
||||
had = await fs.idb.has(uid)
|
||||
if had:
|
||||
had = await fs.idb.delete(uid)
|
||||
|
||||
if had:
|
||||
interpreter._module.FS.unmount(path)
|
||||
|
||||
return had
|
||||
|
||||
|
||||
async def sync(path):
|
||||
"""
|
||||
Synchronise the virtual and local filesystems for a mounted `path`.
|
||||
|
||||
This ensures all changes made in the browser's virtual filesystem are
|
||||
written to the user's local filesystem, and vice versa.
|
||||
|
||||
```python
|
||||
from pyscript import fs
|
||||
|
||||
|
||||
await fs.mount("/local")
|
||||
|
||||
# Make changes to files.
|
||||
with open("/local/data.txt", "w") as f:
|
||||
f.write("Important data")
|
||||
|
||||
# Ensure changes are written to local disk.
|
||||
await fs.sync("/local")
|
||||
```
|
||||
|
||||
This is automatically called by unmount(), but you may want to call
|
||||
it explicitly to ensure data persistence at specific points.
|
||||
"""
|
||||
if path not in mounted:
|
||||
raise KeyError(
|
||||
f"Path '{path}' is not mounted. " f"Use fs.mount() to mount it first."
|
||||
)
|
||||
await mounted[path].syncfs()
|
||||
|
||||
|
||||
async def unmount(path):
|
||||
from _pyscript import interpreter
|
||||
"""
|
||||
Unmount a directory, specified by `path`, from the virtual filesystem.
|
||||
|
||||
This synchronises any pending changes and then removes the mount point,
|
||||
freeing up memory. The `path` can be reused for mounting a different
|
||||
directory.
|
||||
|
||||
```python
|
||||
from pyscript import fs
|
||||
|
||||
|
||||
await fs.mount("/local")
|
||||
# ... work with files ...
|
||||
await fs.unmount("/local")
|
||||
|
||||
# Path can now be reused.
|
||||
await fs.mount("/local", id="different-folder")
|
||||
```
|
||||
|
||||
This automatically calls `sync()` before unmounting to ensure no data
|
||||
is lost.
|
||||
"""
|
||||
if path not in mounted:
|
||||
raise KeyError(f"Path '{path}' is not mounted. Cannot unmount.")
|
||||
|
||||
await sync(path)
|
||||
interpreter._module.FS.unmount(path)
|
||||
del mounted[path]
|
||||
|
||||
|
||||
async def revoke(path, id="pyscript"):
|
||||
"""
|
||||
Revoke filesystem access permission and unmount for a given
|
||||
`path` and `id` combination.
|
||||
|
||||
This removes the stored permission for accessing the user's local
|
||||
filesystem at the specified path and ID. Unlike `unmount()`, which only
|
||||
removes the mount point, `revoke()` also clears the permission so the
|
||||
user will be prompted again on next mount.
|
||||
|
||||
```python
|
||||
from pyscript import fs
|
||||
|
||||
|
||||
await fs.mount("/local", id="my-app")
|
||||
# ... work with files ...
|
||||
|
||||
# Revoke permission (user will be prompted again next time).
|
||||
revoked = await fs.revoke("/local", id="my-app")
|
||||
|
||||
if revoked:
|
||||
print("Permission revoked successfully")
|
||||
```
|
||||
|
||||
After revoking, the user will need to grant permission again and
|
||||
select a directory when `mount()` is called next time.
|
||||
"""
|
||||
mount_key = f"{path}@{id}"
|
||||
|
||||
if RUNNING_IN_WORKER:
|
||||
handler_exists = sync_with_worker.deleteFSHandler(mount_key)
|
||||
else:
|
||||
handler_exists = await _fs.idb.has(mount_key)
|
||||
if handler_exists:
|
||||
handler_exists = await _fs.idb.delete(mount_key)
|
||||
|
||||
if handler_exists:
|
||||
interpreter._module.FS.unmount(path)
|
||||
if path in mounted:
|
||||
del mounted[path]
|
||||
|
||||
return handler_exists
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import json
|
||||
import sys
|
||||
|
||||
import js as globalThis
|
||||
from polyscript import config as _config
|
||||
from polyscript import js_modules
|
||||
from pyscript.util import NotSupported
|
||||
|
||||
RUNNING_IN_WORKER = not hasattr(globalThis, "document")
|
||||
|
||||
config = json.loads(globalThis.JSON.stringify(_config))
|
||||
|
||||
if isinstance(config, str):
|
||||
config = {}
|
||||
|
||||
if "MicroPython" in sys.version:
|
||||
config["type"] = "mpy"
|
||||
else:
|
||||
config["type"] = "py"
|
||||
|
||||
|
||||
# allow `from pyscript.js_modules.xxx import yyy`
|
||||
class JSModule:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __getattr__(self, field):
|
||||
# avoid pyodide looking for non existent fields
|
||||
if not field.startswith("_"):
|
||||
return getattr(getattr(js_modules, self.name), field)
|
||||
return None
|
||||
|
||||
|
||||
# generate N modules in the system that will proxy the real value
|
||||
for name in globalThis.Reflect.ownKeys(js_modules):
|
||||
sys.modules[f"pyscript.js_modules.{name}"] = JSModule(name)
|
||||
sys.modules["pyscript.js_modules"] = js_modules
|
||||
|
||||
if RUNNING_IN_WORKER:
|
||||
import polyscript
|
||||
|
||||
PyWorker = NotSupported(
|
||||
"pyscript.PyWorker",
|
||||
"pyscript.PyWorker works only when running in the main thread",
|
||||
)
|
||||
|
||||
try:
|
||||
import js
|
||||
|
||||
window = polyscript.xworker.window
|
||||
document = window.document
|
||||
js.document = document
|
||||
# this is the same as js_import on main and it lands modules on main
|
||||
js_import = window.Function(
|
||||
"return (...urls) => Promise.all(urls.map((url) => import(url)))"
|
||||
)()
|
||||
except:
|
||||
message = "Unable to use `window` or `document` -> https://docs.pyscript.net/latest/faq/#sharedarraybuffer"
|
||||
globalThis.console.warn(message)
|
||||
window = NotSupported("pyscript.window", message)
|
||||
document = NotSupported("pyscript.document", message)
|
||||
js_import = None
|
||||
|
||||
sync = polyscript.xworker.sync
|
||||
|
||||
# in workers the display does not have a default ID
|
||||
# but there is a sync utility from xworker
|
||||
def current_target():
|
||||
return polyscript.target
|
||||
|
||||
else:
|
||||
import _pyscript
|
||||
from _pyscript import PyWorker as _PyWorker, js_import
|
||||
from pyscript.ffi import to_js
|
||||
|
||||
def PyWorker(url, **kw):
|
||||
return _PyWorker(url, to_js(kw))
|
||||
|
||||
window = globalThis
|
||||
document = globalThis.document
|
||||
sync = NotSupported(
|
||||
"pyscript.sync", "pyscript.sync works only when running in a worker"
|
||||
)
|
||||
|
||||
# in MAIN the current element target exist, just use it
|
||||
def current_target():
|
||||
return _pyscript.target
|
||||
@@ -1,87 +1,247 @@
|
||||
"""
|
||||
This module provides classes and functions for interacting with
|
||||
[media devices and streams](https://developer.mozilla.org/en-US/docs/Web/API/Media_Capture_and_Streams_API)
|
||||
in the browser, enabling you to work with cameras, microphones,
|
||||
and other media input/output devices directly from Python.
|
||||
|
||||
Use this module for:
|
||||
|
||||
- Accessing webcams for video capture.
|
||||
- Recording audio from microphones.
|
||||
- Enumerating available media devices.
|
||||
- Applying constraints to media streams (resolution, frame rate, etc.).
|
||||
|
||||
```python
|
||||
from pyscript import document
|
||||
from pyscript.media import Device, list_devices
|
||||
|
||||
|
||||
# Get a video stream from the default camera.
|
||||
stream = await Device.request_stream(video=True)
|
||||
|
||||
# Display in a video element.
|
||||
video = document.getElementById("my-video")
|
||||
video.srcObject = stream
|
||||
|
||||
# Or list all available devices.
|
||||
devices = await list_devices()
|
||||
for device in devices:
|
||||
print(f"{device.kind}: {device.label}")
|
||||
```
|
||||
|
||||
Using media devices requires user permission. Browsers will show a
|
||||
permission dialog when accessing devices for the first time.
|
||||
"""
|
||||
|
||||
from pyscript import window
|
||||
from pyscript.ffi import to_js
|
||||
|
||||
|
||||
class Device:
|
||||
"""Device represents a media input or output device, such as a microphone,
|
||||
camera, or headset.
|
||||
"""
|
||||
Represents a media input or output device.
|
||||
|
||||
This class wraps a browser
|
||||
[MediaDeviceInfo object](https://developer.mozilla.org/en-US/docs/Web/API/MediaDeviceInfo),
|
||||
providing Pythonic access to device properties like `ID`, `label`, and
|
||||
`kind` (audio/video, input/output).
|
||||
|
||||
Devices are typically obtained via the `list_devices()` function in this
|
||||
module, rather than constructed directly.
|
||||
|
||||
```python
|
||||
from pyscript.media import list_devices
|
||||
|
||||
|
||||
# Get all available devices.
|
||||
devices = await list_devices()
|
||||
|
||||
# Find video input devices (cameras).
|
||||
cameras = [d for d in devices if d.kind == "videoinput"]
|
||||
|
||||
# Get a stream from a specific camera.
|
||||
if cameras:
|
||||
stream = await cameras[0].get_stream()
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, device):
|
||||
self._dom_element = device
|
||||
"""
|
||||
Create a Device wrapper around a MediaDeviceInfo `device`.
|
||||
"""
|
||||
self._device_info = device
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return self._dom_element.deviceId
|
||||
"""
|
||||
Unique identifier for this device.
|
||||
|
||||
This `ID` persists across sessions but is reset when the user clears
|
||||
cookies. It's unique to the origin of the calling application.
|
||||
"""
|
||||
return self._device_info.deviceId
|
||||
|
||||
@property
|
||||
def group(self):
|
||||
return self._dom_element.groupId
|
||||
"""
|
||||
Group identifier for related devices.
|
||||
|
||||
Devices belonging to the same physical device (e.g., a monitor with
|
||||
both a camera and microphone) share the same `group ID`.
|
||||
"""
|
||||
return self._device_info.groupId
|
||||
|
||||
@property
|
||||
def kind(self):
|
||||
return self._dom_element.kind
|
||||
"""
|
||||
Device type: `"videoinput"`, `"audioinput"`, or `"audiooutput"`.
|
||||
"""
|
||||
return self._device_info.kind
|
||||
|
||||
@property
|
||||
def label(self):
|
||||
return self._dom_element.label
|
||||
"""
|
||||
Human-readable description of the device.
|
||||
|
||||
Example: `"External USB Webcam"` or `"Built-in Microphone"`.
|
||||
"""
|
||||
return self._device_info.label
|
||||
|
||||
def __getitem__(self, key):
|
||||
"""
|
||||
Support bracket notation for JavaScript interop.
|
||||
|
||||
Allows accessing properties via `device["id"]` syntax. Necessary
|
||||
when Device instances are proxied to JavaScript.
|
||||
"""
|
||||
return getattr(self, key)
|
||||
|
||||
@classmethod
|
||||
async def request_stream(cls, audio=False, video=True):
|
||||
"""
|
||||
Request a media stream with the specified constraints.
|
||||
|
||||
This is a class method that requests access to media devices matching
|
||||
the given `audio` and `video` constraints. The browser will prompt the
|
||||
user for permission if needed and return a `MediaStream` object that
|
||||
can be assigned to video/audio elements.
|
||||
|
||||
Simple boolean constraints for `audio` and `video` can be used to
|
||||
request default devices. More complex constraints can be specified as
|
||||
dictionaries conforming to
|
||||
[the MediaTrackConstraints interface](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints).
|
||||
|
||||
```python
|
||||
from pyscript import document
|
||||
from pyscript.media import Device
|
||||
|
||||
|
||||
# Get default video stream.
|
||||
stream = await Device.request_stream()
|
||||
|
||||
# Get stream with specific constraints.
|
||||
stream = await Device.request_stream(
|
||||
video={"width": 1920, "height": 1080}
|
||||
)
|
||||
|
||||
# Get audio and video.
|
||||
stream = await Device.request_stream(audio=True, video=True)
|
||||
|
||||
# Use the stream.
|
||||
video_el = document.getElementById("camera")
|
||||
video_el.srcObject = stream
|
||||
```
|
||||
|
||||
This method will trigger a browser permission dialog on first use.
|
||||
"""
|
||||
options = {}
|
||||
if isinstance(audio, bool):
|
||||
options["audio"] = audio
|
||||
elif isinstance(audio, dict):
|
||||
# audio is a dict of constraints (sampleRate, echoCancellation etc...).
|
||||
options["audio"] = audio
|
||||
if isinstance(video, bool):
|
||||
options["video"] = video
|
||||
elif isinstance(video, dict):
|
||||
# video is a dict of constraints (width, height etc...).
|
||||
options["video"] = video
|
||||
return await window.navigator.mediaDevices.getUserMedia(to_js(options))
|
||||
|
||||
@classmethod
|
||||
async def load(cls, audio=False, video=True):
|
||||
"""
|
||||
Load the device stream.
|
||||
!!! warning
|
||||
**Deprecated: Use `request_stream()` instead.**
|
||||
|
||||
This method is retained for backwards compatibility but will be
|
||||
removed in a future release. Please use `request_stream()` instead.
|
||||
"""
|
||||
options = {}
|
||||
options["audio"] = audio
|
||||
if isinstance(video, bool):
|
||||
options["video"] = video
|
||||
else:
|
||||
options["video"] = {}
|
||||
for k in video:
|
||||
options["video"][k] = video[k]
|
||||
return await window.navigator.mediaDevices.getUserMedia(to_js(options))
|
||||
return await cls.request_stream(audio=audio, video=video)
|
||||
|
||||
async def get_stream(self):
|
||||
key = self.kind.replace("input", "").replace("output", "")
|
||||
options = {key: {"deviceId": {"exact": self.id}}}
|
||||
return await self.load(**options)
|
||||
|
||||
|
||||
async def list_devices() -> list[dict]:
|
||||
"""
|
||||
Return the list of the currently available media input and output devices,
|
||||
such as microphones, cameras, headsets, and so forth.
|
||||
Get a media stream from this specific device.
|
||||
|
||||
Output:
|
||||
```python
|
||||
from pyscript.media import list_devices
|
||||
|
||||
list(dict) - list of dictionaries representing the available media devices.
|
||||
Each dictionary has the following keys:
|
||||
* deviceId: a string that is an identifier for the represented device
|
||||
that is persisted across sessions. It is un-guessable by other
|
||||
applications and unique to the origin of the calling application.
|
||||
It is reset when the user clears cookies (for Private Browsing, a
|
||||
different identifier is used that is not persisted across sessions).
|
||||
|
||||
* groupId: a string that is a group identifier. Two devices have the same
|
||||
group identifier if they belong to the same physical device — for
|
||||
example a monitor with both a built-in camera and a microphone.
|
||||
# List all devices.
|
||||
devices = await list_devices()
|
||||
|
||||
* kind: an enumerated value that is either "videoinput", "audioinput"
|
||||
or "audiooutput".
|
||||
# Find a specific camera.
|
||||
my_camera = None
|
||||
for device in devices:
|
||||
if device.kind == "videoinput" and "USB" in device.label:
|
||||
my_camera = device
|
||||
break
|
||||
|
||||
* label: a string describing this device (for example "External USB
|
||||
Webcam").
|
||||
# Get a stream from that specific camera.
|
||||
if my_camera:
|
||||
stream = await my_camera.get_stream()
|
||||
```
|
||||
|
||||
Note: the returned list will omit any devices that are blocked by the document
|
||||
Permission Policy: microphone, camera, speaker-selection (for output devices),
|
||||
and so on. Access to particular non-default devices is also gated by the
|
||||
Permissions API, and the list will omit devices for which the user has not
|
||||
granted explicit permission.
|
||||
This will trigger a permission dialog if the user hasn't already
|
||||
granted permission for this device type.
|
||||
"""
|
||||
# https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices
|
||||
return [
|
||||
Device(obj) for obj in await window.navigator.mediaDevices.enumerateDevices()
|
||||
]
|
||||
# Extract media type from device kind (e.g., "videoinput" -> "video").
|
||||
media_type = self.kind.replace("input", "").replace("output", "")
|
||||
# Request stream with exact device ID constraint.
|
||||
options = {media_type: {"deviceId": {"exact": self.id}}}
|
||||
return await self.request_stream(**options)
|
||||
|
||||
|
||||
async def list_devices():
|
||||
"""
|
||||
Returns a list of all media devices currently available to the browser,
|
||||
such as microphones, cameras, and speakers.
|
||||
|
||||
```python
|
||||
from pyscript.media import list_devices
|
||||
|
||||
|
||||
# Get all devices.
|
||||
devices = await list_devices()
|
||||
|
||||
# Print device information.
|
||||
for device in devices:
|
||||
print(f"{device.kind}: {device.label} (ID: {device.id})")
|
||||
|
||||
# Filter for specific device types.
|
||||
cameras = [d for d in devices if d.kind == "videoinput"]
|
||||
microphones = [d for d in devices if d.kind == "audioinput"]
|
||||
speakers = [d for d in devices if d.kind == "audiooutput"]
|
||||
```
|
||||
|
||||
The returned list will omit devices that are blocked by the document
|
||||
[Permission Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Permissions_Policy)
|
||||
(microphone, camera, speaker-selection) or for
|
||||
which the user has not granted explicit permission.
|
||||
|
||||
For security and privacy, device labels may be empty strings until
|
||||
permission is granted. See
|
||||
[this document](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices)
|
||||
for more information about this web standard.
|
||||
"""
|
||||
device_infos = await window.navigator.mediaDevices.enumerateDevices()
|
||||
return [Device(device_info) for device_info in device_infos]
|
||||
|
||||
@@ -1,11 +1,69 @@
|
||||
from polyscript import storage as _storage
|
||||
"""
|
||||
This module wraps the browser's
|
||||
[IndexedDB persistent storage](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API)
|
||||
to provide a familiar Python dictionary API. Data is automatically
|
||||
serialized and persisted, surviving page reloads and browser restarts.
|
||||
|
||||
Storage is persistent per origin (domain), isolated between different sites
|
||||
for security. Browsers typically allow each origin to store up to 10-60% of
|
||||
total disk space, depending on browser and configuration.
|
||||
|
||||
What this module provides:
|
||||
|
||||
- A `dict`-like API (get, set, delete, iterate).
|
||||
- Automatic serialization of common Python types.
|
||||
- Background persistence with optional explicit `sync()`.
|
||||
- Support for custom `Storage` subclasses.
|
||||
|
||||
```python
|
||||
from pyscript import storage
|
||||
|
||||
|
||||
# Create or open a named storage.
|
||||
my_data = await storage("user-preferences")
|
||||
|
||||
# Use like a regular dictionary.
|
||||
my_data["theme"] = "dark"
|
||||
my_data["font_size"] = 14
|
||||
my_data["settings"] = {"notifications": True, "sound": False}
|
||||
|
||||
# Changes are queued automatically.
|
||||
# To ensure immediate write, sync explicitly.
|
||||
await my_data.sync()
|
||||
|
||||
# Read values (survives page reload).
|
||||
theme = my_data.get("theme", "light")
|
||||
```
|
||||
|
||||
Common types are automatically serialized: `bool`, `int`, `float`, `str`, `None`,
|
||||
`list`, `dict`, `tuple`. Binary data (`bytearray`, `memoryview`) can be stored as
|
||||
single values but not nested in structures.
|
||||
|
||||
Tuples are deserialized as lists due to IndexedDB limitations.
|
||||
|
||||
!!! info
|
||||
Browsers typically allow 10-60% of total disk space per origin. Chrome
|
||||
and Edge allow up to 60%, Firefox up to 10 GiB (or 10% of disk, whichever
|
||||
is smaller). Safari varies by app type. These limits are unlikely to be
|
||||
reached in typical usage.
|
||||
"""
|
||||
|
||||
from polyscript import storage as _polyscript_storage
|
||||
from pyscript.flatted import parse as _parse
|
||||
from pyscript.flatted import stringify as _stringify
|
||||
from pyscript.ffi import is_none
|
||||
|
||||
|
||||
# convert a Python value into an IndexedDB compatible entry
|
||||
def _to_idb(value):
|
||||
def _convert_to_idb(value):
|
||||
"""
|
||||
Convert a Python `value` to an IndexedDB-compatible format.
|
||||
|
||||
Values are serialized using Flatted (for circular reference support)
|
||||
with type information to enable proper deserialization. It returns a
|
||||
JSON string representing the serialized value.
|
||||
|
||||
Will raise a TypeError if the value type is not supported.
|
||||
"""
|
||||
if is_none(value):
|
||||
return _stringify(["null", 0])
|
||||
if isinstance(value, (bool, float, int, str, list, dict, tuple)):
|
||||
@@ -14,50 +72,179 @@ def _to_idb(value):
|
||||
return _stringify(["bytearray", list(value)])
|
||||
if isinstance(value, memoryview):
|
||||
return _stringify(["memoryview", list(value)])
|
||||
msg = f"Unexpected value: {value}"
|
||||
raise TypeError(msg)
|
||||
raise TypeError(f"Cannot serialize type {type(value).__name__} for storage.")
|
||||
|
||||
|
||||
# convert an IndexedDB compatible entry into a Python value
|
||||
def _from_idb(value):
|
||||
(
|
||||
kind,
|
||||
result,
|
||||
) = _parse(value)
|
||||
def _convert_from_idb(value):
|
||||
"""
|
||||
Convert an IndexedDB `value` back to its Python representation.
|
||||
|
||||
Uses type information stored during serialization to reconstruct the
|
||||
original Python type.
|
||||
"""
|
||||
kind, data = _parse(value)
|
||||
|
||||
if kind == "null":
|
||||
return None
|
||||
if kind == "generic":
|
||||
return result
|
||||
return data
|
||||
if kind == "bytearray":
|
||||
return bytearray(result)
|
||||
return bytearray(data)
|
||||
if kind == "memoryview":
|
||||
return memoryview(bytearray(result))
|
||||
return memoryview(bytearray(data))
|
||||
# Fallback for all other types.
|
||||
return value
|
||||
|
||||
|
||||
class Storage(dict):
|
||||
"""
|
||||
A persistent dictionary backed by the browser's IndexedDB.
|
||||
|
||||
This class provides a dict-like interface with automatic persistence.
|
||||
Changes are queued for background writing, with optional explicit
|
||||
synchronization via `sync()`.
|
||||
|
||||
Inherits from `dict`, so all standard dictionary methods work as expected.
|
||||
|
||||
```python
|
||||
from pyscript import storage
|
||||
|
||||
|
||||
# Open a storage.
|
||||
prefs = await storage("preferences")
|
||||
|
||||
# Use as a dictionary.
|
||||
prefs["color"] = "blue"
|
||||
prefs["count"] = 42
|
||||
|
||||
# Iterate like a dict.
|
||||
for key, value in prefs.items():
|
||||
print(f"{key}: {value}")
|
||||
|
||||
# Ensure writes complete immediately.
|
||||
await prefs.sync()
|
||||
```
|
||||
|
||||
Sometimes you may need to subclass `Storage` to add custom behavior:
|
||||
|
||||
```python
|
||||
from pyscript import storage, Storage, window
|
||||
|
||||
|
||||
class LoggingStorage(Storage):
|
||||
def __setitem__(self, key, value):
|
||||
window.console.log(f"Setting {key} = {value}")
|
||||
super().__setitem__(key, value)
|
||||
|
||||
my_store = await storage("app-data", storage_class=LoggingStorage)
|
||||
my_store["test"] = 123 # Logs to console.
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, store):
|
||||
super().__init__({k: _from_idb(v) for k, v in store.entries()})
|
||||
self.__store__ = store
|
||||
"""
|
||||
Create a Storage instance wrapping an IndexedDB `store` (a JS
|
||||
proxy).
|
||||
"""
|
||||
super().__init__(
|
||||
{key: _convert_from_idb(value) for key, value in store.entries()}
|
||||
)
|
||||
self._store = store
|
||||
|
||||
def __delitem__(self, attr):
|
||||
self.__store__.delete(attr)
|
||||
super().__delitem__(attr)
|
||||
def __delitem__(self, key):
|
||||
"""
|
||||
Delete an item from storage via its `key`.
|
||||
|
||||
def __setitem__(self, attr, value):
|
||||
self.__store__.set(attr, _to_idb(value))
|
||||
super().__setitem__(attr, value)
|
||||
The deletion is queued for persistence. Use `sync()` to ensure
|
||||
immediate completion.
|
||||
"""
|
||||
self._store.delete(key)
|
||||
super().__delitem__(key)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
"""
|
||||
Set a `key` to a `value` in storage.
|
||||
|
||||
The change is queued for persistence. Use `sync()` to ensure
|
||||
immediate completion. The `value` must be a supported type for
|
||||
serialization.
|
||||
"""
|
||||
self._store.set(key, _convert_to_idb(value))
|
||||
super().__setitem__(key, value)
|
||||
|
||||
def clear(self):
|
||||
self.__store__.clear()
|
||||
"""
|
||||
Remove all items from storage.
|
||||
|
||||
The `clear()` operation is queued for persistence. Use `sync()` to ensure
|
||||
immediate completion.
|
||||
"""
|
||||
self._store.clear()
|
||||
super().clear()
|
||||
|
||||
async def sync(self):
|
||||
await self.__store__.sync()
|
||||
"""
|
||||
Force immediate synchronization to IndexedDB.
|
||||
|
||||
By default, storage operations are queued and written asynchronously.
|
||||
Call `sync()` when you need to guarantee changes are persisted immediately,
|
||||
such as before critical operations or page unload.
|
||||
|
||||
```python
|
||||
store = await storage("important-data")
|
||||
store["critical_value"] = data
|
||||
|
||||
# Ensure it's written before proceeding.
|
||||
await store.sync()
|
||||
```
|
||||
|
||||
This is a blocking operation that waits for IndexedDB to complete
|
||||
the write.
|
||||
"""
|
||||
await self._store.sync()
|
||||
|
||||
|
||||
async def storage(name="", storage_class=Storage):
|
||||
"""
|
||||
Open or create persistent storage with a unique `name` and optional
|
||||
`storage_class` (used to extend the default `Storage` based behavior).
|
||||
|
||||
Each storage is isolated by name within the current origin (domain).
|
||||
If the storage doesn't exist, it will be created. If it does exist,
|
||||
its current contents will be loaded.
|
||||
|
||||
This function returns a `Storage` instance (or custom subclass instance)
|
||||
acting as a persistent dictionary. A `ValueError` is raised if `name` is
|
||||
empty or not provided.
|
||||
|
||||
```python
|
||||
from pyscript import storage
|
||||
|
||||
|
||||
# Basic usage.
|
||||
user_data = await storage("user-profile")
|
||||
user_data["name"] = "Alice"
|
||||
user_data["age"] = 30
|
||||
|
||||
# Multiple independent storages.
|
||||
settings = await storage("app-settings")
|
||||
cache = await storage("api-cache")
|
||||
|
||||
# With custom Storage class.
|
||||
class ValidatingStorage(Storage):
|
||||
def __setitem__(self, key, value):
|
||||
if not isinstance(key, str):
|
||||
raise TypeError("Keys must be strings")
|
||||
super().__setitem__(key, value)
|
||||
|
||||
validated = await storage("validated-data", ValidatingStorage)
|
||||
```
|
||||
|
||||
Storage names are automatically prefixed with `"@pyscript/"` to
|
||||
namespace them within IndexedDB.
|
||||
"""
|
||||
if not name:
|
||||
msg = "The storage name must be defined"
|
||||
raise ValueError(msg)
|
||||
return storage_class(await _storage(f"@pyscript/{name}"))
|
||||
raise ValueError("Storage name must be a non-empty string")
|
||||
|
||||
underlying_store = await _polyscript_storage(f"@pyscript/{name}")
|
||||
return storage_class(underlying_store)
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
"""
|
||||
This module contains general-purpose utility functions that don't fit into
|
||||
more specific modules. These utilities handle cross-platform compatibility
|
||||
between Pyodide and MicroPython, feature detection, and common type
|
||||
conversions:
|
||||
|
||||
- `as_bytearray`: Convert JavaScript `ArrayBuffer` to Python `bytearray`.
|
||||
- `NotSupported`: Placeholder for unavailable features in specific contexts.
|
||||
- `is_awaitable`: Detect `async` functions across Python implementations.
|
||||
|
||||
These utilities are primarily used internally by PyScript but are available
|
||||
for use in application code when needed.
|
||||
"""
|
||||
|
||||
import js
|
||||
import sys
|
||||
import inspect
|
||||
|
||||
|
||||
def as_bytearray(buffer):
|
||||
"""
|
||||
Given a JavaScript ArrayBuffer, convert it to a Python bytearray in a
|
||||
Given a JavaScript `ArrayBuffer`, convert it to a Python `bytearray` in a
|
||||
MicroPython friendly manner.
|
||||
"""
|
||||
ui8a = js.Uint8Array.new(buffer)
|
||||
@@ -42,17 +55,25 @@ class NotSupported:
|
||||
def is_awaitable(obj):
|
||||
"""
|
||||
Returns a boolean indication if the passed in obj is an awaitable
|
||||
function. (MicroPython treats awaitables as generator functions, and if
|
||||
the object is a closure containing an async function we need to work
|
||||
carefully.)
|
||||
function. This is interpreter agnostic.
|
||||
|
||||
!!! info
|
||||
MicroPython treats awaitables as generator functions, and if
|
||||
the object is a closure containing an async function or a bound method
|
||||
we need to work carefully.
|
||||
"""
|
||||
from pyscript import config
|
||||
|
||||
if config["type"] == "mpy": # Is MicroPython?
|
||||
if config["type"] == "mpy":
|
||||
# MicroPython doesn't appear to have a way to determine if a closure is
|
||||
# an async function except via the repr. This is a bit hacky.
|
||||
if "<closure <generator>" in repr(obj):
|
||||
r = repr(obj)
|
||||
if "<closure <generator>" in r:
|
||||
return True
|
||||
# Same applies to bound methods.
|
||||
if "<bound_method" in r and "<generator>" in r:
|
||||
return True
|
||||
# In MicroPython, generator functions are awaitable.
|
||||
return inspect.isgeneratorfunction(obj)
|
||||
|
||||
return inspect.iscoroutinefunction(obj)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,88 +1,295 @@
|
||||
"""
|
||||
This module provides a Pythonic wrapper around the browser's
|
||||
[WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket),
|
||||
enabling two-way communication with WebSocket servers.
|
||||
|
||||
Use this for real-time applications:
|
||||
|
||||
- Pythonic interface to browser WebSockets.
|
||||
- Automatic handling of async event handlers.
|
||||
- Support for receiving text (`str`) and binary (`memoryview`) data.
|
||||
- Support for sending text (`str`) and binary (`bytes` and `bytearray`) data.
|
||||
- Compatible with Pyodide and MicroPython.
|
||||
- Works in webworker contexts.
|
||||
- Naming deliberately follows the JavaScript WebSocket API closely for
|
||||
familiarity.
|
||||
|
||||
See the Python docs for
|
||||
[an explanation of memoryview](https://docs.python.org/3/library/stdtypes.html#memoryview).
|
||||
|
||||
|
||||
```python
|
||||
from pyscript import WebSocket
|
||||
|
||||
|
||||
def on_open(event):
|
||||
print("Connected!")
|
||||
ws.send("Hello server")
|
||||
|
||||
def on_message(event):
|
||||
print(f"Received: {event.data}")
|
||||
|
||||
def on_close(event):
|
||||
print("Connection closed")
|
||||
|
||||
ws = WebSocket(url="ws://localhost:8080/")
|
||||
ws.onopen = on_open
|
||||
ws.onmessage = on_message
|
||||
ws.onclose = on_close
|
||||
```
|
||||
"""
|
||||
|
||||
import js
|
||||
from pyscript.ffi import create_proxy
|
||||
from pyscript.util import as_bytearray, is_awaitable
|
||||
|
||||
code = "code"
|
||||
protocols = "protocols"
|
||||
reason = "reason"
|
||||
methods = ["onclose", "onerror", "onmessage", "onopen"]
|
||||
|
||||
def _attach_event_handler(websocket, handler_name, handler_function):
|
||||
"""
|
||||
Given a `websocket`, and `handler_name`, attach the `handler_function`
|
||||
to the `WebSocket` instance, handling both synchronous and asynchronous
|
||||
handler functions.
|
||||
|
||||
def add_listener(socket, onevent, listener):
|
||||
p = create_proxy(listener)
|
||||
Creates a JavaScript proxy for the handler and wraps async handlers
|
||||
appropriately. Handles the `WebSocketEvent` wrapping for all handlers.
|
||||
"""
|
||||
if is_awaitable(handler_function):
|
||||
|
||||
if is_awaitable(listener):
|
||||
|
||||
async def wrapper(e):
|
||||
await p(EventMessage(e))
|
||||
|
||||
m = wrapper
|
||||
async def async_wrapper(event):
|
||||
await handler_function(WebSocketEvent(event))
|
||||
|
||||
wrapped_handler = create_proxy(async_wrapper)
|
||||
else:
|
||||
m = lambda e: p(EventMessage(e))
|
||||
|
||||
# Pyodide fails at setting socket[onevent] directly
|
||||
setattr(socket, onevent, m)
|
||||
wrapped_handler = create_proxy(
|
||||
lambda event: handler_function(WebSocketEvent(event))
|
||||
)
|
||||
# Note: Direct assignment (websocket[handler_name]) fails in Pyodide.
|
||||
setattr(websocket, handler_name, wrapped_handler)
|
||||
|
||||
|
||||
class EventMessage:
|
||||
class WebSocketEvent:
|
||||
"""
|
||||
A read-only wrapper for
|
||||
[WebSocket event objects](https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent).
|
||||
|
||||
This class wraps browser WebSocket events and provides convenient access
|
||||
to event properties. It handles the conversion of binary data from
|
||||
JavaScript typed arrays to Python bytes-like objects.
|
||||
|
||||
The most commonly used property is `event.data`, which contains the
|
||||
message data for "message" events.
|
||||
|
||||
```python
|
||||
def on_message(event): # The event is a WebSocketEvent instance.
|
||||
# For text messages.
|
||||
if isinstance(event.data, str):
|
||||
print(f"Text: {event.data}")
|
||||
else:
|
||||
# For binary messages.
|
||||
print(f"Binary: {len(event.data)} bytes")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, event):
|
||||
"""
|
||||
Create a WebSocketEvent wrapper from an underlying JavaScript
|
||||
`event`.
|
||||
"""
|
||||
self._event = event
|
||||
|
||||
def __getattr__(self, attr):
|
||||
value = getattr(self._event, attr)
|
||||
"""
|
||||
Get an attribute `attr` from the underlying event object.
|
||||
|
||||
Handles special conversion of binary data from JavaScript typed
|
||||
arrays to Python `memoryview` objects.
|
||||
"""
|
||||
value = getattr(self._event, attr)
|
||||
if attr == "data" and not isinstance(value, str):
|
||||
if hasattr(value, "to_py"):
|
||||
# Pyodide - convert JavaScript typed array to Python.
|
||||
return value.to_py()
|
||||
# shims in MicroPython
|
||||
else:
|
||||
# MicroPython - manually convert JS ArrayBuffer.
|
||||
return memoryview(as_bytearray(value))
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class WebSocket:
|
||||
"""
|
||||
This class provides a Python-friendly interface to WebSocket connections,
|
||||
handling communication with WebSocket servers. It supports both text and
|
||||
binary data transmission.
|
||||
|
||||
Access the underlying WebSocket methods and properties directly if needed.
|
||||
However, the wrapper provides a more Pythonic API. If you need to work
|
||||
with the raw JavaScript WebSocket instance, you can access it via the
|
||||
`_js_websocket` attribute.
|
||||
|
||||
Using textual (`str`) data:
|
||||
|
||||
```python
|
||||
from pyscript import WebSocket
|
||||
|
||||
|
||||
# Create WebSocket with handlers as arguments.
|
||||
def handle_message(event):
|
||||
print(f"Got: {event.data}")
|
||||
|
||||
ws = WebSocket(
|
||||
url="ws://echo.websocket.org/",
|
||||
onmessage=handle_message
|
||||
)
|
||||
|
||||
# Or assign handlers after creation.
|
||||
def handle_open(event):
|
||||
ws.send("Hello!")
|
||||
|
||||
ws.onopen = handle_open
|
||||
```
|
||||
|
||||
Using binary (`memoryview`) data:
|
||||
|
||||
```python
|
||||
def handle_message(event):
|
||||
if isinstance(event.data, str):
|
||||
print(f"Text: {event.data}")
|
||||
else:
|
||||
# Binary data as memoryview.
|
||||
print(f"Binary: {len(event.data)} bytes")
|
||||
|
||||
ws = WebSocket(url="ws://example.com/", onmessage=handle_message)
|
||||
|
||||
# Send binary data.
|
||||
data = bytearray([0x01, 0x02, 0x03])
|
||||
ws.send(data)
|
||||
```
|
||||
|
||||
Read more about Python's
|
||||
[`memoryview` here](https://docs.python.org/3/library/stdtypes.html#memoryview).
|
||||
"""
|
||||
|
||||
# WebSocket ready state constants.
|
||||
CONNECTING = 0
|
||||
OPEN = 1
|
||||
CLOSING = 2
|
||||
CLOSED = 3
|
||||
|
||||
def __init__(self, **kw):
|
||||
url = kw["url"]
|
||||
if protocols in kw:
|
||||
socket = js.WebSocket.new(url, kw[protocols])
|
||||
def __init__(self, url, protocols=None, **handlers):
|
||||
"""
|
||||
Create a new WebSocket connection from the given `url` (`ws://` or
|
||||
`wss://`). Optionally specify `protocols` (a string or a list of
|
||||
protocol strings) and event handlers (`onopen`, `onmessage`, etc.) as
|
||||
keyword arguments.
|
||||
|
||||
These arguments and naming conventions mirror those of the
|
||||
[underlying JavaScript WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
|
||||
for familiarity.
|
||||
|
||||
If you need access to the underlying JavaScript WebSocket instance,
|
||||
you can get it via the `_js_websocket` attribute.
|
||||
|
||||
```python
|
||||
# Basic connection.
|
||||
ws = WebSocket(url="ws://localhost:8080/")
|
||||
|
||||
# With protocol.
|
||||
ws = WebSocket(
|
||||
url="wss://example.com/socket",
|
||||
protocols="chat"
|
||||
)
|
||||
|
||||
# With handlers.
|
||||
ws = WebSocket(
|
||||
url="ws://localhost:8080/",
|
||||
onopen=lambda e: print("Connected"),
|
||||
onmessage=lambda e: print(e.data)
|
||||
)
|
||||
```
|
||||
"""
|
||||
# Create underlying JavaScript WebSocket.
|
||||
if protocols:
|
||||
js_websocket = js.WebSocket.new(url, protocols)
|
||||
else:
|
||||
socket = js.WebSocket.new(url)
|
||||
|
||||
socket.binaryType = "arraybuffer"
|
||||
object.__setattr__(self, "_ws", socket)
|
||||
|
||||
for t in methods:
|
||||
if t in kw:
|
||||
add_listener(socket, t, kw[t])
|
||||
js_websocket = js.WebSocket.new(url)
|
||||
# Set binary type to arraybuffer for easier Python handling.
|
||||
js_websocket.binaryType = "arraybuffer"
|
||||
# Store the underlying WebSocket.
|
||||
# Use object.__setattr__ to bypass our custom __setattr__.
|
||||
object.__setattr__(self, "_js_websocket", js_websocket)
|
||||
# Attach any event handlers passed as keyword arguments.
|
||||
for handler_name, handler in handlers.items():
|
||||
setattr(self, handler_name, handler)
|
||||
|
||||
def __getattr__(self, attr):
|
||||
return getattr(self._ws, attr)
|
||||
"""
|
||||
Get an attribute `attr` from the underlying WebSocket.
|
||||
|
||||
This allows transparent access to WebSocket properties like
|
||||
`readyState`, `url`, `bufferedAmount`, etc.
|
||||
"""
|
||||
return getattr(self._js_websocket, attr)
|
||||
|
||||
def __setattr__(self, attr, value):
|
||||
if attr in methods:
|
||||
add_listener(self._ws, attr, value)
|
||||
else:
|
||||
setattr(self._ws, attr, value)
|
||||
"""
|
||||
Set an attribute `attr` on the WebSocket to the given `value`.
|
||||
|
||||
def close(self, **kw):
|
||||
if code in kw and reason in kw:
|
||||
self._ws.close(kw[code], kw[reason])
|
||||
elif code in kw:
|
||||
self._ws.close(kw[code])
|
||||
Event handler attributes (`onopen`, `onmessage`, etc.) are specially
|
||||
handled to create proper proxies. Other attributes are set on the
|
||||
underlying WebSocket directly.
|
||||
"""
|
||||
if attr in ["onclose", "onerror", "onmessage", "onopen"]:
|
||||
_attach_event_handler(self._js_websocket, attr, value)
|
||||
else:
|
||||
self._ws.close()
|
||||
setattr(self._js_websocket, attr, value)
|
||||
|
||||
def send(self, data):
|
||||
"""
|
||||
Send `data` through the WebSocket.
|
||||
|
||||
Accepts both text (`str`) and binary data (`bytes`, `bytearray`, etc.).
|
||||
Binary data is automatically converted to a JavaScript `Uint8Array`.
|
||||
|
||||
```python
|
||||
# Send text.
|
||||
ws.send("Hello server!")
|
||||
|
||||
# Send binary.
|
||||
ws.send(bytes([1, 2, 3, 4]))
|
||||
ws.send(bytearray([5, 6, 7, 8]))
|
||||
```
|
||||
|
||||
!!! warning
|
||||
|
||||
The WebSocket **must be in the OPEN state to send data**.
|
||||
"""
|
||||
if isinstance(data, str):
|
||||
self._ws.send(data)
|
||||
self._js_websocket.send(data)
|
||||
else:
|
||||
buffer = js.Uint8Array.new(len(data))
|
||||
for pos, b in enumerate(data):
|
||||
buffer[pos] = b
|
||||
self._ws.send(buffer)
|
||||
for index, byte_value in enumerate(data):
|
||||
buffer[index] = byte_value
|
||||
self._js_websocket.send(buffer)
|
||||
|
||||
def close(self, code=None, reason=None):
|
||||
"""
|
||||
Close the WebSocket connection. Optionally specify a `code` (`int`)
|
||||
and a `reason` (`str`) for closing the connection.
|
||||
|
||||
```python
|
||||
# Normal close.
|
||||
ws.close()
|
||||
|
||||
# Close with code and reason.
|
||||
ws.close(code=1000, reason="Task completed")
|
||||
```
|
||||
|
||||
Usage and values for `code` and `reasons`
|
||||
[are explained here](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close).
|
||||
"""
|
||||
if code and reason:
|
||||
self._js_websocket.close(code, reason)
|
||||
elif code:
|
||||
self._js_websocket.close(code)
|
||||
else:
|
||||
self._js_websocket.close()
|
||||
|
||||
@@ -1,45 +1,194 @@
|
||||
import js as _js
|
||||
from polyscript import workers as _workers
|
||||
"""
|
||||
This module provides access to named
|
||||
[web workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API)
|
||||
defined in `<script>` tags, and utilities for dynamically creating workers
|
||||
from Python code.
|
||||
|
||||
_get = _js.Reflect.get
|
||||
Named workers are Python web workers defined in HTML with a `name` attribute
|
||||
that can be referenced from the main thread or other workers. This module
|
||||
provides the `workers` object for accessing named workers and the
|
||||
`create_named_worker()` function for dynamically creating them.
|
||||
|
||||
Accessing named workers:
|
||||
|
||||
```html
|
||||
<!-- Define a named worker -->
|
||||
<script type="py" worker name="calculator">
|
||||
def add(a, b):
|
||||
return a + b
|
||||
|
||||
__export__ = ["add"]
|
||||
</script>
|
||||
|
||||
<!-- Access from main thread -->
|
||||
<script type="mpy">
|
||||
from pyscript import workers
|
||||
|
||||
|
||||
def _set(script, name, value=""):
|
||||
script.setAttribute(name, value)
|
||||
calc = await workers["calculator"]
|
||||
result = await calc.add(5, 3)
|
||||
print(result) # 8
|
||||
</script>
|
||||
```
|
||||
|
||||
Dynamically creating named workers:
|
||||
|
||||
```python
|
||||
from pyscript import create_named_worker
|
||||
|
||||
|
||||
# this solves an inconsistency between Pyodide and MicroPython
|
||||
# @see https://github.com/pyscript/pyscript/issues/2106
|
||||
class _ReadOnlyProxy:
|
||||
# Create a worker from a Python file.
|
||||
worker = await create_named_worker(
|
||||
src="./background_tasks.py",
|
||||
name="task-processor"
|
||||
)
|
||||
|
||||
# Use the worker's exported functions.
|
||||
result = await worker.process_data([1, 2, 3, 4, 5])
|
||||
print(result)
|
||||
```
|
||||
|
||||
Key features:
|
||||
- Access (`await`) named workers via dictionary-like syntax.
|
||||
- Dynamically create workers from Python.
|
||||
- Cross-interpreter support (Pyodide and MicroPython).
|
||||
|
||||
Worker access is asynchronous - you must `await workers[name]` to get
|
||||
a reference to the worker. This is because workers may not be ready
|
||||
immediately at startup.
|
||||
"""
|
||||
|
||||
import js
|
||||
import json
|
||||
from polyscript import workers as _polyscript_workers
|
||||
|
||||
|
||||
class _ReadOnlyWorkersProxy:
|
||||
"""
|
||||
A read-only proxy for accessing named web workers. Use
|
||||
`create_named_worker()` to create new workers found in this proxy.
|
||||
|
||||
This provides dictionary-like access to named workers defined in
|
||||
the page. It handles differences between Pyodide and MicroPython
|
||||
implementations transparently.
|
||||
|
||||
(See: https://github.com/pyscript/pyscript/issues/2106 for context.)
|
||||
|
||||
The proxy is read-only to prevent accidental modification of the
|
||||
underlying workers registry. Both item access and attribute access are
|
||||
supported for convenience (especially since HTML attribute names may
|
||||
not be valid Python identifiers).
|
||||
|
||||
```python
|
||||
from pyscript import workers
|
||||
|
||||
# Access a named worker.
|
||||
my_worker = await workers["worker-name"]
|
||||
result = await my_worker.some_function()
|
||||
|
||||
# Alternatively, if the name works, access via attribute notation.
|
||||
my_worker = await workers.worker_name
|
||||
result = await my_worker.some_function()
|
||||
```
|
||||
|
||||
**This is a proxy object, not a dict**. You cannot iterate over it or
|
||||
get a list of worker names. This is intentional because worker
|
||||
startup timing is non-deterministic.
|
||||
"""
|
||||
|
||||
def __getitem__(self, name):
|
||||
return _get(_workers, name)
|
||||
"""
|
||||
Get a named worker by `name`. It returns a promise that resolves to
|
||||
the worker reference when ready.
|
||||
|
||||
This is useful if the underlying worker name is not a valid Python
|
||||
identifier.
|
||||
|
||||
```python
|
||||
worker = await workers["my-worker"]
|
||||
```
|
||||
"""
|
||||
return js.Reflect.get(_polyscript_workers, name)
|
||||
|
||||
def __getattr__(self, name):
|
||||
return _get(_workers, name)
|
||||
"""
|
||||
Get a named worker as an attribute. It returns a promise that resolves
|
||||
to the worker reference when ready.
|
||||
|
||||
This allows accessing workers via dot notation as an alternative
|
||||
to bracket notation.
|
||||
|
||||
```python
|
||||
worker = await workers.my_worker
|
||||
```
|
||||
"""
|
||||
return js.Reflect.get(_polyscript_workers, name)
|
||||
|
||||
|
||||
workers = _ReadOnlyProxy()
|
||||
# Global workers proxy for accessing named workers.
|
||||
workers = _ReadOnlyWorkersProxy()
|
||||
"""Global proxy for accessing named web workers."""
|
||||
|
||||
|
||||
async def create_named_worker(src="", name="", config=None, type="py"):
|
||||
from json import dumps
|
||||
async def create_named_worker(src, name, config=None, type="py"):
|
||||
"""
|
||||
Dynamically create a web worker with a `src` Python file, a unique
|
||||
`name` and optional `config` (dict or JSON string) and `type` (`py`
|
||||
for Pyodide or `mpy` for MicroPython, the default is `py`).
|
||||
|
||||
if not src:
|
||||
msg = "Named workers require src"
|
||||
raise ValueError(msg)
|
||||
This function creates a new web worker by injecting a `<script>` tag into
|
||||
the document. The worker will be accessible via the `workers` proxy once
|
||||
it's ready.
|
||||
|
||||
if not name:
|
||||
msg = "Named workers require a name"
|
||||
raise ValueError(msg)
|
||||
It returns a promise that resolves to the worker reference when ready.
|
||||
|
||||
s = _js.document.createElement("script")
|
||||
s.type = type
|
||||
s.src = src
|
||||
_set(s, "worker")
|
||||
_set(s, "name", name)
|
||||
```python
|
||||
from pyscript import create_named_worker
|
||||
|
||||
|
||||
# Create a Pyodide worker.
|
||||
worker = await create_named_worker(
|
||||
src="./my_worker.py",
|
||||
name="background-worker"
|
||||
)
|
||||
|
||||
# Use the worker.
|
||||
result = await worker.process_data()
|
||||
|
||||
# Create with standard PyScript configuration.
|
||||
worker = await create_named_worker(
|
||||
src="./processor.py",
|
||||
name="data-processor",
|
||||
config={"packages": ["numpy", "pandas"]}
|
||||
)
|
||||
|
||||
# Use MicroPython instead.
|
||||
worker = await create_named_worker(
|
||||
src="./lightweight_worker.py",
|
||||
name="micro-worker",
|
||||
type="mpy"
|
||||
)
|
||||
```
|
||||
|
||||
!!! info
|
||||
|
||||
**The worker script should define** `__export__` to specify which
|
||||
functions or objects are accessible from the main thread.
|
||||
"""
|
||||
# Create script element for the worker.
|
||||
script = js.document.createElement("script")
|
||||
script.type = type
|
||||
script.src = src
|
||||
# Mark as a worker with a name.
|
||||
script.setAttribute("worker", "")
|
||||
script.setAttribute("name", name)
|
||||
# Add configuration if provided.
|
||||
if config:
|
||||
_set(s, "config", (isinstance(config, str) and config) or dumps(config))
|
||||
|
||||
_js.document.body.append(s)
|
||||
if isinstance(config, str):
|
||||
config_str = config
|
||||
else:
|
||||
config_str = json.dumps(config)
|
||||
script.setAttribute("config", config_str)
|
||||
# Inject the script into the document and await the result.
|
||||
js.document.body.append(script)
|
||||
return await workers[name]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const timeout = 60 * 1000;
|
||||
const timeout = 120 * 1000;
|
||||
|
||||
test.setTimeout(timeout);
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
|
||||
<div id="div-no-classes"></div>
|
||||
|
||||
<script type="py" worker name="testworker" src="./worker_functions.py"></script>
|
||||
|
||||
<div style="visibility: hidden;">
|
||||
<h2>Test Read and Write</h2>
|
||||
<div id="test_rr_div">Content test_rr_div</div>
|
||||
@@ -63,6 +65,7 @@
|
||||
<button>I'm another button you can click</button>
|
||||
<button id="a-third-button">2 is better than 3 :)</button>
|
||||
<button id="another-test-button">I'm another button to be clicked</button>
|
||||
<button id="button-for-event-testing">Button for event testing</button>
|
||||
|
||||
<div id="element-append-tests"></div>
|
||||
<p class="collection"></p>
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
{
|
||||
"files": {
|
||||
"https://raw.githubusercontent.com/ntoll/upytest/1.0.9/upytest.py": "",
|
||||
"https://raw.githubusercontent.com/ntoll/upytest/1.0.11/upytest.py": "",
|
||||
"./tests/test_config.py": "tests/test_config.py",
|
||||
"./tests/test_context.py": "tests/test_context.py",
|
||||
"./tests/test_current_target.py": "tests/test_current_target.py",
|
||||
"./tests/test_display.py": "tests/test_display.py",
|
||||
"./tests/test_document.py": "tests/test_document.py",
|
||||
"./tests/test_events.py": "tests/test_events.py",
|
||||
"./tests/test_fetch.py": "tests/test_fetch.py",
|
||||
"./tests/test_ffi.py": "tests/test_ffi.py",
|
||||
"./tests/test_js_modules.py": "tests/test_js_modules.py",
|
||||
"./tests/test_fs.py": "tests/test_fs.py",
|
||||
"./tests/test_media.py": "tests/test_media.py",
|
||||
"./tests/test_storage.py": "tests/test_storage.py",
|
||||
"./tests/test_util.py": "tests/test_util.py",
|
||||
"./tests/test_running_in_worker.py": "tests/test_running_in_worker.py",
|
||||
"./tests/test_web.py": "tests/test_web.py",
|
||||
"./tests/test_websocket.py": "tests/test_websocket.py",
|
||||
"./tests/test_events.py": "tests/test_events.py",
|
||||
"./tests/test_window.py": "tests/test_window.py"
|
||||
"./tests/test_window.py": "tests/test_window.py",
|
||||
"./tests/test_workers.py": "tests/test_workers.py"
|
||||
},
|
||||
"js_modules": {
|
||||
"main": {
|
||||
"./example_js_module.js": "greeting"
|
||||
},
|
||||
"worker": {
|
||||
"./example_js_worker_module.js": "greeting_worker"
|
||||
}
|
||||
"main": {"./example_js_module.js": "greeting"},
|
||||
"worker": {"./example_js_worker_module.js": "greeting_worker"}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
{
|
||||
"files": {
|
||||
"https://raw.githubusercontent.com/ntoll/upytest/1.0.9/upytest.py": "",
|
||||
"https://raw.githubusercontent.com/ntoll/upytest/1.0.11/upytest.py": "",
|
||||
"./tests/test_config.py": "tests/test_config.py",
|
||||
"./tests/test_context.py": "tests/test_context.py",
|
||||
"./tests/test_current_target.py": "tests/test_current_target.py",
|
||||
"./tests/test_display.py": "tests/test_display.py",
|
||||
"./tests/test_document.py": "tests/test_document.py",
|
||||
"./tests/test_events.py": "tests/test_events.py",
|
||||
"./tests/test_fetch.py": "tests/test_fetch.py",
|
||||
"./tests/test_ffi.py": "tests/test_ffi.py",
|
||||
"./tests/test_fs.py": "tests/test_fs.py",
|
||||
"./tests/test_media.py": "tests/test_media.py",
|
||||
"./tests/test_js_modules.py": "tests/test_js_modules.py",
|
||||
"./tests/test_storage.py": "tests/test_storage.py",
|
||||
"./tests/test_util.py": "tests/test_util.py",
|
||||
"./tests/test_running_in_worker.py": "tests/test_running_in_worker.py",
|
||||
"./tests/test_web.py": "tests/test_web.py",
|
||||
"./tests/test_websocket.py": "tests/test_websocket.py",
|
||||
"./tests/test_events.py": "tests/test_events.py",
|
||||
"./tests/test_window.py": "tests/test_window.py"
|
||||
"./tests/test_window.py": "tests/test_window.py",
|
||||
"./tests/test_workers.py": "tests/test_workers.py"
|
||||
},
|
||||
"js_modules": {
|
||||
"main": {
|
||||
"./example_js_module.js": "greeting"
|
||||
"main": {"./example_js_module.js": "greeting"},
|
||||
"worker": {"./example_js_worker_module.js": "greeting_worker"}
|
||||
},
|
||||
"worker": {
|
||||
"./example_js_worker_module.js": "greeting_worker"
|
||||
}
|
||||
},
|
||||
"packages": ["Pillow" ],
|
||||
"packages": ["Pillow"],
|
||||
"experimental_ffi_timeout": 0
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ def test_current_target():
|
||||
"""
|
||||
expected = "py-0"
|
||||
if is_micropython:
|
||||
expected = "mpy-w0-target" if RUNNING_IN_WORKER else "mpy-0"
|
||||
expected = "mpy-w1-target" if RUNNING_IN_WORKER else "mpy-0"
|
||||
elif RUNNING_IN_WORKER:
|
||||
expected = "py-w0-target"
|
||||
expected = "py-w1-target"
|
||||
assert current_target() == expected, f"Expected {expected} got {current_target()}"
|
||||
|
||||
@@ -3,6 +3,7 @@ Tests for the display function in PyScript.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import upytest
|
||||
from pyscript import HTML, RUNNING_IN_WORKER, display, py_import, web
|
||||
@@ -107,20 +108,7 @@ def test_empty_string_target_raises_value_error():
|
||||
"""
|
||||
with upytest.raises(ValueError) as exc:
|
||||
display("hello world", target="")
|
||||
assert str(exc.exception) == "Cannot have an empty target"
|
||||
|
||||
|
||||
def test_non_string_target_values_raise_typerror():
|
||||
"""
|
||||
The target parameter must be a string.
|
||||
"""
|
||||
with upytest.raises(TypeError) as exc:
|
||||
display("hello world", target=True)
|
||||
assert str(exc.exception) == "target must be str or None, not bool"
|
||||
|
||||
with upytest.raises(TypeError) as exc:
|
||||
display("hello world", target=123)
|
||||
assert str(exc.exception) == "target must be str or None, not int"
|
||||
assert str(exc.exception) == "Cannot find element with id='' in the page."
|
||||
|
||||
|
||||
async def test_tag_target_attribute():
|
||||
@@ -286,4 +274,406 @@ async def test_image_renders_correctly():
|
||||
display(img, target="test-element-container", append=False)
|
||||
target = web.page.find("#test-element-container")[0]
|
||||
img = target.find("img")[0]
|
||||
assert img.src.startswith("data:image/png;charset=utf-8;base64")
|
||||
assert img.src.startswith("data:image/png;base64"), img.src
|
||||
|
||||
|
||||
async def test_mimebundle_simple():
|
||||
"""
|
||||
An object with _repr_mimebundle_ should use the mimebundle formats.
|
||||
"""
|
||||
|
||||
class MimebundleObj:
|
||||
def _repr_mimebundle_(self):
|
||||
return {
|
||||
"text/html": "<strong>Bold HTML</strong>",
|
||||
"text/plain": "Plain text fallback",
|
||||
}
|
||||
|
||||
display(MimebundleObj())
|
||||
container = await get_display_container()
|
||||
# Should prefer HTML from mimebundle.
|
||||
assert container[0].innerHTML == "<strong>Bold HTML</strong>"
|
||||
|
||||
|
||||
async def test_mimebundle_with_metadata():
|
||||
"""
|
||||
Mimebundle can include metadata for specific MIME types.
|
||||
"""
|
||||
|
||||
class ImageWithMeta:
|
||||
def _repr_mimebundle_(self):
|
||||
return (
|
||||
{
|
||||
"image/png": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
},
|
||||
{"image/png": {"width": "100", "height": "50"}},
|
||||
)
|
||||
|
||||
display(ImageWithMeta(), target="test-element-container", append=False)
|
||||
target = web.page.find("#test-element-container")[0]
|
||||
img = target.find("img")[0]
|
||||
assert img.getAttribute("width") == "100"
|
||||
assert img.getAttribute("height") == "50"
|
||||
|
||||
|
||||
async def test_mimebundle_with_tuple_output():
|
||||
"""
|
||||
Mimebundle format values can be tuples with (data, metadata).
|
||||
"""
|
||||
|
||||
class TupleOutput:
|
||||
def _repr_mimebundle_(self):
|
||||
return {"text/html": ("<em>Italic</em>", {"custom": "meta"})}
|
||||
|
||||
display(TupleOutput())
|
||||
container = await get_display_container()
|
||||
assert container[0].innerHTML == "<em>Italic</em>"
|
||||
|
||||
|
||||
async def test_mimebundle_metadata_merge():
|
||||
"""
|
||||
Format-specific metadata should merge with global metadata.
|
||||
"""
|
||||
|
||||
class MetaMerge:
|
||||
def _repr_mimebundle_(self):
|
||||
return (
|
||||
{
|
||||
"image/png": (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
{"height": "75"},
|
||||
)
|
||||
},
|
||||
{"image/png": {"width": "100"}},
|
||||
)
|
||||
|
||||
display(MetaMerge(), target="test-element-container", append=False)
|
||||
target = web.page.find("#test-element-container")[0]
|
||||
img = target.find("img")[0]
|
||||
# Both global and format-specific metadata should be present.
|
||||
assert img.getAttribute("width") == "100"
|
||||
assert img.getAttribute("height") == "75"
|
||||
|
||||
|
||||
async def test_mimebundle_unsupported_mime():
|
||||
"""
|
||||
If mimebundle contains only unsupported MIME types, fall back to regular methods.
|
||||
"""
|
||||
|
||||
class UnsupportedMime:
|
||||
def _repr_mimebundle_(self):
|
||||
return {"application/pdf": "PDF data", "text/latex": "LaTeX data"}
|
||||
|
||||
def _repr_html_(self):
|
||||
return "<p>HTML fallback</p>"
|
||||
|
||||
display(UnsupportedMime())
|
||||
container = await get_display_container()
|
||||
# Should fall back to _repr_html_.
|
||||
assert container[0].innerHTML == "<p>HTML fallback</p>"
|
||||
|
||||
|
||||
async def test_mimebundle_no_dict():
|
||||
"""
|
||||
Mimebundle that returns just a dict (no tuple) should work.
|
||||
"""
|
||||
|
||||
class SimpleMimebundle:
|
||||
def _repr_mimebundle_(self):
|
||||
return {"text/html": "<code>Code</code>"}
|
||||
|
||||
display(SimpleMimebundle())
|
||||
container = await get_display_container()
|
||||
assert container[0].innerHTML == "<code>Code</code>"
|
||||
|
||||
|
||||
async def test_repr_html():
|
||||
"""
|
||||
Objects with _repr_html_ should render as HTML.
|
||||
"""
|
||||
|
||||
class HTMLRepr:
|
||||
def _repr_html_(self):
|
||||
return "<h1>HTML Header</h1>"
|
||||
|
||||
display(HTMLRepr())
|
||||
container = await get_display_container()
|
||||
assert container[0].innerHTML == "<h1>HTML Header</h1>"
|
||||
|
||||
|
||||
async def test_repr_html_with_metadata():
|
||||
"""
|
||||
_repr_html_ can return (html, metadata) tuple.
|
||||
"""
|
||||
|
||||
class HTMLWithMeta:
|
||||
def _repr_html_(self):
|
||||
return ("<p>Paragraph</p>", {"data-custom": "value"})
|
||||
|
||||
display(HTMLWithMeta())
|
||||
container = await get_display_container()
|
||||
# Metadata is not used in _repr_html_ rendering, but ensure HTML is
|
||||
# correct.
|
||||
assert container[0].innerHTML == "<p>Paragraph</p>"
|
||||
|
||||
|
||||
async def test_repr_svg():
|
||||
"""
|
||||
Objects with _repr_svg_ should render as SVG.
|
||||
"""
|
||||
|
||||
class SVGRepr:
|
||||
def _repr_svg_(self):
|
||||
return '<svg width="100" height="100"><circle cx="50" cy="50" r="40" fill="blue"/></svg>'
|
||||
|
||||
display(SVGRepr(), target="test-element-container", append=False)
|
||||
target = web.page.find("#test-element-container")[0]
|
||||
assert "svg" in target.innerHTML.lower()
|
||||
assert "circle" in target.innerHTML.lower()
|
||||
|
||||
|
||||
async def test_repr_json():
|
||||
"""
|
||||
Objects with _repr_json_ should render as JSON.
|
||||
"""
|
||||
|
||||
class JSONRepr:
|
||||
def _repr_json_(self):
|
||||
return '{"key": "value", "number": 42}'
|
||||
|
||||
display(JSONRepr(), target="test-element-container", append=False)
|
||||
target = web.page.find("#test-element-container")[0]
|
||||
assert '"key": "value"' in target.innerHTML
|
||||
value = json.loads(target.innerText)
|
||||
assert value["key"] == "value"
|
||||
assert value["number"] == 42
|
||||
|
||||
|
||||
async def test_repr_png_bytes():
|
||||
"""
|
||||
_repr_png_ can render raw bytes.
|
||||
"""
|
||||
|
||||
class PNGBytes:
|
||||
def _repr_png_(self):
|
||||
# Valid 1x1 transparent PNG as bytes.
|
||||
return b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
|
||||
display(PNGBytes(), target="test-element-container", append=False)
|
||||
target = web.page.find("#test-element-container")[0]
|
||||
img = target.find("img")[0]
|
||||
assert img.src.startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
async def test_repr_png_base64():
|
||||
"""
|
||||
_repr_png_ can render a base64-encoded string.
|
||||
"""
|
||||
|
||||
class PNGBase64:
|
||||
def _repr_png_(self):
|
||||
return "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
|
||||
display(PNGBase64(), target="test-element-container", append=False)
|
||||
target = web.page.find("#test-element-container")[0]
|
||||
img = target.find("img")[0]
|
||||
assert img.src.startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
async def test_repr_jpeg():
|
||||
"""
|
||||
Objects with _repr_jpeg_ should render as JPEG images.
|
||||
"""
|
||||
|
||||
class JPEGRepr:
|
||||
def _repr_jpeg_(self):
|
||||
# Minimal valid JPEG header (won't display but tests the path).
|
||||
return b"\xff\xd8\xff\xe0\x00\x10JFIF"
|
||||
|
||||
display(JPEGRepr(), target="test-element-container", append=False)
|
||||
target = web.page.find("#test-element-container")[0]
|
||||
img = target.find("img")[0]
|
||||
assert img.src.startswith("data:image/jpeg;base64,")
|
||||
|
||||
|
||||
async def test_repr_jpeg_base64():
|
||||
"""
|
||||
_repr_jpeg_ can render a base64-encoded string.
|
||||
"""
|
||||
|
||||
class JPEGBase64:
|
||||
def _repr_jpeg_(self):
|
||||
return "ZCBqcGVnIG1pbmltdW0=="
|
||||
|
||||
display(JPEGBase64(), target="test-element-container", append=False)
|
||||
target = web.page.find("#test-element-container")[0]
|
||||
img = target.find("img")[0]
|
||||
assert img.src.startswith("data:image/jpeg;base64,")
|
||||
|
||||
|
||||
async def test_object_with_no_repr_methods():
|
||||
"""
|
||||
Objects with no representation methods should fall back to __repr__ with warning.
|
||||
"""
|
||||
|
||||
class NoReprMethods:
|
||||
pass
|
||||
|
||||
obj = NoReprMethods()
|
||||
display(obj)
|
||||
container = await get_display_container()
|
||||
# Should contain the default repr output - the class name. :-)
|
||||
assert "NoReprMethods" in container.innerText
|
||||
|
||||
|
||||
async def test_repr_method_returns_none():
|
||||
"""
|
||||
If a repr method exists but returns None, try next method.
|
||||
"""
|
||||
|
||||
class NoneReturner:
|
||||
def _repr_html_(self):
|
||||
return None
|
||||
|
||||
def __repr__(self):
|
||||
return "Fallback repr"
|
||||
|
||||
display(NoneReturner())
|
||||
container = await get_display_container()
|
||||
assert container.innerText == "Fallback repr"
|
||||
|
||||
|
||||
async def test_multiple_repr_methods_priority():
|
||||
"""
|
||||
When multiple repr methods exist, should use first available in priority order.
|
||||
"""
|
||||
|
||||
class MultipleReprs:
|
||||
def _repr_html_(self):
|
||||
# Highest priority.
|
||||
return "<p>HTML version</p>"
|
||||
|
||||
def __repr__(self):
|
||||
# Lower priority.
|
||||
return "Text version"
|
||||
|
||||
display(MultipleReprs())
|
||||
container = await get_display_container()
|
||||
# Should use HTML, not repr.
|
||||
assert container[0].innerHTML == "<p>HTML version</p>"
|
||||
|
||||
|
||||
async def test_empty_string_display():
|
||||
"""
|
||||
Empty strings are ignored.
|
||||
"""
|
||||
display("")
|
||||
container = await get_display_container()
|
||||
assert len(container.children) == 0
|
||||
|
||||
|
||||
async def test_newline_string_skipped():
|
||||
"""
|
||||
Single newline strings are skipped (legacy behavior).
|
||||
"""
|
||||
display("\n")
|
||||
container = await get_display_container()
|
||||
# Should be empty because newlines are skipped.
|
||||
assert len(container.children) == 0
|
||||
|
||||
|
||||
async def test_string_with_special_html_chars():
|
||||
"""
|
||||
Strings with HTML special characters should be escaped.
|
||||
"""
|
||||
display("<script>alert('xss')</script>")
|
||||
container = await get_display_container()
|
||||
assert "<script>" in container[0].innerHTML
|
||||
assert "<script>" not in container[0].innerHTML
|
||||
|
||||
|
||||
async def test_javascript_mime_type():
|
||||
"""
|
||||
JavaScript MIME type should create script tags.
|
||||
"""
|
||||
|
||||
class JSRepr:
|
||||
def _repr_javascript_(self):
|
||||
return "console.log('test');"
|
||||
|
||||
display(JSRepr(), target="test-element-container", append=False)
|
||||
target = web.page.find("#test-element-container")[0]
|
||||
assert "<script>" in target.innerHTML
|
||||
assert "console.log" in target.innerHTML
|
||||
|
||||
|
||||
async def test_append_false_clears_multiple_children():
|
||||
"""
|
||||
append=False should clear all existing children, not just the last one.
|
||||
"""
|
||||
# Add some initial content.
|
||||
display("child 1")
|
||||
display("child 2")
|
||||
display("child 3")
|
||||
container = await get_display_container()
|
||||
assert len(container.children) == 3 # three divs.
|
||||
|
||||
# Now display with append=False.
|
||||
display("new content", append=False)
|
||||
container = await get_display_container()
|
||||
# No divs used, just the new textual content.
|
||||
assert container.innerText == "new content"
|
||||
|
||||
|
||||
async def test_mixed_append_true_false():
|
||||
"""
|
||||
Mixing append=True and append=False should work correctly.
|
||||
"""
|
||||
display("first", append=True)
|
||||
display("second", append=True)
|
||||
display("third", append=False)
|
||||
container = await get_display_container()
|
||||
assert container.innerText == "third"
|
||||
|
||||
|
||||
def test_target_with_multiple_hashes():
|
||||
"""
|
||||
Target with multiple # characters should only strip the first one.
|
||||
|
||||
Such an id is not valid in HTML, but we should handle it gracefully.
|
||||
"""
|
||||
# Should try to find element with id="#weird-id".
|
||||
# This will raise ValueError as it doesn't exist.
|
||||
with upytest.raises(ValueError):
|
||||
display("content", target="##weird-id")
|
||||
|
||||
|
||||
async def test_display_none_value():
|
||||
"""
|
||||
Displaying None should use its repr.
|
||||
"""
|
||||
display(None)
|
||||
container = await get_display_container()
|
||||
assert container.innerText == "None"
|
||||
|
||||
|
||||
async def test_display_boolean_values():
|
||||
"""
|
||||
Booleans should display as their repr.
|
||||
"""
|
||||
display(True, False)
|
||||
container = await get_display_container()
|
||||
assert "True" in container.innerText
|
||||
assert "False" in container.innerText
|
||||
|
||||
|
||||
async def test_display_numbers():
|
||||
"""
|
||||
Numbers should display correctly.
|
||||
"""
|
||||
display(42, 3.14159, -17)
|
||||
container = await get_display_container()
|
||||
text = container.innerText
|
||||
assert "42" in text
|
||||
assert "3.14159" in text
|
||||
assert "-17" in text
|
||||
|
||||
@@ -24,15 +24,26 @@ def teardown():
|
||||
|
||||
def test_event_add_listener():
|
||||
"""
|
||||
Adding a listener to an event should add it to the list of listeners. It
|
||||
should only be added once.
|
||||
Adding a listener to an event should add it to the list of listeners.
|
||||
It should only be added once.
|
||||
"""
|
||||
event = Event()
|
||||
listener = lambda x: x
|
||||
event.add_listener(listener)
|
||||
event.add_listener(listener)
|
||||
assert len(event._listeners) == 1 # Only one item added.
|
||||
assert listener in event._listeners # The item is the expected listener.
|
||||
assert len(event._listeners) == 1
|
||||
assert listener in event._listeners
|
||||
|
||||
|
||||
def test_event_add_non_callable_listener():
|
||||
"""
|
||||
Adding a non-callable listener should raise a ValueError.
|
||||
"""
|
||||
event = Event()
|
||||
with upytest.raises(ValueError):
|
||||
event.add_listener("not a callable")
|
||||
with upytest.raises(ValueError):
|
||||
event.add_listener(123)
|
||||
|
||||
|
||||
def test_event_remove_listener():
|
||||
@@ -45,12 +56,25 @@ def test_event_remove_listener():
|
||||
listener2 = lambda x: x
|
||||
event.add_listener(listener1)
|
||||
event.add_listener(listener2)
|
||||
assert len(event._listeners) == 2 # Two listeners added.
|
||||
assert listener1 in event._listeners # The first listener is in the list.
|
||||
assert listener2 in event._listeners # The second listener is in the list.
|
||||
assert len(event._listeners) == 2
|
||||
assert listener1 in event._listeners
|
||||
assert listener2 in event._listeners
|
||||
event.remove_listener(listener1)
|
||||
assert len(event._listeners) == 1 # Only one item remains.
|
||||
assert listener2 in event._listeners # The second listener is in the list.
|
||||
assert len(event._listeners) == 1
|
||||
assert listener2 in event._listeners
|
||||
|
||||
|
||||
def test_event_remove_nonexistent_listener():
|
||||
"""
|
||||
Removing a listener that doesn't exist should be silently ignored.
|
||||
"""
|
||||
event = Event()
|
||||
listener1 = lambda x: x
|
||||
listener2 = lambda x: x
|
||||
event.add_listener(listener1)
|
||||
event.remove_listener(listener2)
|
||||
assert len(event._listeners) == 1
|
||||
assert listener1 in event._listeners
|
||||
|
||||
|
||||
def test_event_remove_all_listeners():
|
||||
@@ -62,15 +86,15 @@ def test_event_remove_all_listeners():
|
||||
listener2 = lambda x: x
|
||||
event.add_listener(listener1)
|
||||
event.add_listener(listener2)
|
||||
assert len(event._listeners) == 2 # Two listeners added.
|
||||
assert len(event._listeners) == 2
|
||||
event.remove_listener()
|
||||
assert len(event._listeners) == 0 # No listeners remain.
|
||||
assert len(event._listeners) == 0
|
||||
|
||||
|
||||
def test_event_trigger():
|
||||
"""
|
||||
Triggering an event should call all of the listeners with the provided
|
||||
arguments.
|
||||
result.
|
||||
"""
|
||||
event = Event()
|
||||
counter = 0
|
||||
@@ -81,15 +105,45 @@ def test_event_trigger():
|
||||
assert x == "ok"
|
||||
|
||||
event.add_listener(listener)
|
||||
assert counter == 0 # The listener has not been triggered yet.
|
||||
assert counter == 0
|
||||
event.trigger("ok")
|
||||
assert counter == 1 # The listener has been triggered with the expected result.
|
||||
assert counter == 1
|
||||
|
||||
|
||||
def test_event_trigger_no_listeners():
|
||||
"""
|
||||
Triggering an event with no listeners should not raise an error.
|
||||
"""
|
||||
event = Event()
|
||||
event.trigger("test")
|
||||
|
||||
|
||||
def test_event_trigger_multiple_listeners():
|
||||
"""
|
||||
Triggering an event should call all registered listeners.
|
||||
"""
|
||||
event = Event()
|
||||
results = []
|
||||
|
||||
def listener1(x):
|
||||
results.append(f"listener1: {x}")
|
||||
|
||||
def listener2(x):
|
||||
results.append(f"listener2: {x}")
|
||||
|
||||
event.add_listener(listener1)
|
||||
event.add_listener(listener2)
|
||||
event.trigger("test")
|
||||
|
||||
assert len(results) == 2
|
||||
assert "listener1: test" in results
|
||||
assert "listener2: test" in results
|
||||
|
||||
|
||||
async def test_event_trigger_with_awaitable():
|
||||
"""
|
||||
Triggering an event with an awaitable listener should call the listener
|
||||
with the provided arguments.
|
||||
with the provided result.
|
||||
"""
|
||||
call_flag = asyncio.Event()
|
||||
event = Event()
|
||||
@@ -102,16 +156,119 @@ async def test_event_trigger_with_awaitable():
|
||||
call_flag.set()
|
||||
|
||||
event.add_listener(listener)
|
||||
assert counter == 0 # The listener has not been triggered yet.
|
||||
assert counter == 0
|
||||
event.trigger("ok")
|
||||
await call_flag.wait()
|
||||
assert counter == 1 # The listener has been triggered with the expected result.
|
||||
assert counter == 1
|
||||
|
||||
|
||||
async def test_event_trigger_mixed_listeners():
|
||||
"""
|
||||
Triggering an event with both sync and async listeners should work.
|
||||
"""
|
||||
event = Event()
|
||||
sync_called = False
|
||||
async_flag = asyncio.Event()
|
||||
|
||||
def sync_listener(x):
|
||||
nonlocal sync_called
|
||||
sync_called = True
|
||||
assert x == "mixed"
|
||||
|
||||
async def async_listener(x):
|
||||
assert x == "mixed"
|
||||
async_flag.set()
|
||||
|
||||
event.add_listener(sync_listener)
|
||||
event.add_listener(async_listener)
|
||||
event.trigger("mixed")
|
||||
|
||||
assert sync_called
|
||||
await async_flag.wait()
|
||||
|
||||
|
||||
def test_event_listener_exception():
|
||||
"""
|
||||
If a listener raises an exception, it should propagate and not be
|
||||
silently ignored.
|
||||
"""
|
||||
event = Event()
|
||||
|
||||
def bad_listener(x):
|
||||
raise RuntimeError("Listener error")
|
||||
|
||||
event.add_listener(bad_listener)
|
||||
|
||||
with upytest.raises(RuntimeError):
|
||||
event.trigger("test")
|
||||
|
||||
|
||||
def test_event_listener_exception_stops_other_listeners():
|
||||
"""
|
||||
If a listener raises an exception, subsequent listeners should not be
|
||||
called. There's a problem with the user's code that needs to be addressed!
|
||||
"""
|
||||
event = Event()
|
||||
called = []
|
||||
|
||||
def listener1(x):
|
||||
called.append("listener1")
|
||||
|
||||
def bad_listener(x):
|
||||
called.append("bad_listener")
|
||||
raise RuntimeError("Listener error")
|
||||
|
||||
def listener3(x):
|
||||
called.append("listener3")
|
||||
|
||||
event.add_listener(listener1)
|
||||
event.add_listener(bad_listener)
|
||||
event.add_listener(listener3)
|
||||
|
||||
with upytest.raises(RuntimeError):
|
||||
event.trigger("test")
|
||||
|
||||
assert "listener1" in called
|
||||
assert "bad_listener" in called
|
||||
assert "listener3" not in called
|
||||
|
||||
|
||||
async def test_event_async_listener_exception():
|
||||
"""
|
||||
If an async listener raises an exception, it cannot prevent other
|
||||
listeners from being called, as async listeners run as tasks. This
|
||||
is different behavior from sync listeners, but the simplest model
|
||||
for users to understand.
|
||||
|
||||
This test ensures that even if one async listener fails, others
|
||||
still run as per this expected behaviour. In MicroPython, the
|
||||
exception will be reported to the user.
|
||||
"""
|
||||
event = Event()
|
||||
call_flag = asyncio.Event()
|
||||
called = []
|
||||
|
||||
async def bad_listener(x):
|
||||
called.append("bad_listener")
|
||||
raise RuntimeError("Async listener error")
|
||||
|
||||
async def good_listener(x):
|
||||
called.append("good_listener")
|
||||
call_flag.set()
|
||||
|
||||
event.add_listener(bad_listener)
|
||||
event.add_listener(good_listener)
|
||||
event.trigger("test")
|
||||
|
||||
await call_flag.wait()
|
||||
assert "bad_listener" in called
|
||||
assert "good_listener" in called
|
||||
|
||||
|
||||
async def test_when_decorator_with_event():
|
||||
"""
|
||||
When the decorated function takes a single parameter,
|
||||
it should be passed the event object.
|
||||
When the decorated function takes a single parameter, it should be
|
||||
passed the event object.
|
||||
"""
|
||||
btn = web.button("foo_button", id="foo_id")
|
||||
container = get_container()
|
||||
@@ -133,8 +290,8 @@ async def test_when_decorator_with_event():
|
||||
|
||||
async def test_when_decorator_without_event():
|
||||
"""
|
||||
When the decorated function takes no parameters (not including 'self'),
|
||||
it should be called without the event object.
|
||||
When the decorated function takes no parameters, it should be called
|
||||
without the event object.
|
||||
"""
|
||||
btn = web.button("foo_button", id="foo_id")
|
||||
container = get_container()
|
||||
@@ -143,7 +300,7 @@ async def test_when_decorator_without_event():
|
||||
called = False
|
||||
call_flag = asyncio.Event()
|
||||
|
||||
@web.when("click", selector="#foo_id")
|
||||
@when("click", selector="#foo_id")
|
||||
def foo():
|
||||
nonlocal called
|
||||
called = True
|
||||
@@ -156,8 +313,8 @@ async def test_when_decorator_without_event():
|
||||
|
||||
async def test_when_decorator_with_event_as_async_handler():
|
||||
"""
|
||||
When the decorated function takes a single parameter,
|
||||
it should be passed the event object. Async version.
|
||||
When the decorated function takes a single parameter, it should be
|
||||
passed the event object. Async version.
|
||||
"""
|
||||
btn = web.button("foo_button", id="foo_id")
|
||||
container = get_container()
|
||||
@@ -179,8 +336,8 @@ async def test_when_decorator_with_event_as_async_handler():
|
||||
|
||||
async def test_when_decorator_without_event_as_async_handler():
|
||||
"""
|
||||
When the decorated function takes no parameters (not including 'self'),
|
||||
it should be called without the event object. Async version.
|
||||
When the decorated function takes no parameters, it should be called
|
||||
without the event object. Async version.
|
||||
"""
|
||||
btn = web.button("foo_button", id="foo_id")
|
||||
container = get_container()
|
||||
@@ -189,7 +346,7 @@ async def test_when_decorator_without_event_as_async_handler():
|
||||
called = False
|
||||
call_flag = asyncio.Event()
|
||||
|
||||
@web.when("click", selector="#foo_id")
|
||||
@when("click", selector="#foo_id")
|
||||
async def foo():
|
||||
nonlocal called
|
||||
called = True
|
||||
@@ -202,7 +359,7 @@ async def test_when_decorator_without_event_as_async_handler():
|
||||
|
||||
async def test_two_when_decorators():
|
||||
"""
|
||||
When decorating a function twice, both should function
|
||||
When decorating a function twice, both should function independently.
|
||||
"""
|
||||
btn = web.button("foo_button", id="foo_id")
|
||||
container = get_container()
|
||||
@@ -235,7 +392,7 @@ async def test_two_when_decorators():
|
||||
async def test_when_decorator_multiple_elements():
|
||||
"""
|
||||
The @when decorator's selector should successfully select multiple
|
||||
DOM elements
|
||||
DOM elements.
|
||||
"""
|
||||
btn1 = web.button(
|
||||
"foo_button1",
|
||||
@@ -283,7 +440,8 @@ async def test_when_decorator_multiple_elements():
|
||||
)
|
||||
def test_when_decorator_invalid_selector():
|
||||
"""
|
||||
When the selector parameter of @when is invalid, it should raise an error.
|
||||
When the selector parameter of @when is invalid, it should raise an
|
||||
error.
|
||||
"""
|
||||
if upytest.is_micropython:
|
||||
from jsffi import JsException
|
||||
@@ -298,139 +456,320 @@ def test_when_decorator_invalid_selector():
|
||||
assert "'#.bad' is not a valid selector" in str(e.exception), str(e.exception)
|
||||
|
||||
|
||||
def test_when_missing_selector_for_dom_event():
|
||||
"""
|
||||
When using @when with a DOM event but no selector, should raise
|
||||
ValueError.
|
||||
"""
|
||||
with upytest.raises(ValueError):
|
||||
|
||||
@when("click")
|
||||
def handler(event):
|
||||
pass
|
||||
|
||||
|
||||
def test_when_empty_selector_finds_no_elements():
|
||||
"""
|
||||
When selector matches no elements, should raise ValueError.
|
||||
"""
|
||||
with upytest.raises(ValueError):
|
||||
|
||||
@when("click", "#nonexistent-element-id-12345")
|
||||
def handler(event):
|
||||
pass
|
||||
|
||||
|
||||
def test_when_decorates_an_event():
|
||||
"""
|
||||
When the @when decorator is used on a function to handle an Event instance,
|
||||
the function should be called when the Event object is triggered.
|
||||
When the @when decorator is used on a function to handle an Event
|
||||
instance, the function should be called when the Event object is
|
||||
triggered.
|
||||
"""
|
||||
|
||||
whenable = Event()
|
||||
counter = 0
|
||||
|
||||
# When as a decorator.
|
||||
@when(whenable)
|
||||
def handler(result):
|
||||
"""
|
||||
A function that should be called when the whenable object is triggered.
|
||||
|
||||
The result generated by the whenable object should be passed to the
|
||||
function.
|
||||
A function that should be called when the whenable object is
|
||||
triggered.
|
||||
"""
|
||||
nonlocal counter
|
||||
counter += 1
|
||||
assert result == "ok"
|
||||
|
||||
# The function should not be called until the whenable object is triggered.
|
||||
assert counter == 0
|
||||
# Trigger the whenable object.
|
||||
whenable.trigger("ok")
|
||||
# The function should have been called when the whenable object was
|
||||
# triggered.
|
||||
assert counter == 1
|
||||
|
||||
|
||||
def test_when_called_with_an_event_and_handler():
|
||||
async def test_when_with_list_of_events():
|
||||
"""
|
||||
The when function should be able to be called with an Event object,
|
||||
and a handler function.
|
||||
The @when decorator should handle a list of Event objects.
|
||||
"""
|
||||
whenable = Event()
|
||||
event1 = Event()
|
||||
event2 = Event()
|
||||
counter = 0
|
||||
|
||||
@when([event1, event2])
|
||||
def handler(result):
|
||||
"""
|
||||
A function that should be called when the whenable object is triggered.
|
||||
|
||||
The result generated by the whenable object should be passed to the
|
||||
function.
|
||||
"""
|
||||
nonlocal counter
|
||||
counter += 1
|
||||
assert result == "ok"
|
||||
|
||||
# When as a function.
|
||||
when(whenable, handler)
|
||||
|
||||
# The function should not be called until the whenable object is triggered.
|
||||
assert counter == 0
|
||||
# Trigger the whenable object.
|
||||
whenable.trigger("ok")
|
||||
# The function should have been called when the whenable object was
|
||||
# triggered.
|
||||
event1.trigger("test1")
|
||||
assert counter == 1
|
||||
event2.trigger("test2")
|
||||
assert counter == 2
|
||||
|
||||
|
||||
async def test_when_with_async_event_handler():
|
||||
"""
|
||||
Async handlers should work with custom Event objects.
|
||||
"""
|
||||
event = Event()
|
||||
call_flag = asyncio.Event()
|
||||
counter = 0
|
||||
|
||||
@when(event)
|
||||
async def handler(result):
|
||||
nonlocal counter
|
||||
counter += 1
|
||||
assert result == "async test"
|
||||
call_flag.set()
|
||||
|
||||
assert counter == 0
|
||||
event.trigger("async test")
|
||||
await call_flag.wait()
|
||||
assert counter == 1
|
||||
|
||||
def test_when_on_different_callables():
|
||||
|
||||
async def test_when_with_element_selector():
|
||||
"""
|
||||
The when function works with:
|
||||
|
||||
* Synchronous functions
|
||||
* Asynchronous functions
|
||||
* Inner functions
|
||||
* Async inner functions
|
||||
* Closure functions
|
||||
* Async closure functions
|
||||
The @when decorator should accept an Element object as selector.
|
||||
"""
|
||||
btn = web.button("test", id="elem_selector_test")
|
||||
container = get_container()
|
||||
container.append(btn)
|
||||
|
||||
def func(x=1):
|
||||
# A simple function.
|
||||
return x
|
||||
called = False
|
||||
call_flag = asyncio.Event()
|
||||
|
||||
async def a_func(x=1):
|
||||
# A simple async function.
|
||||
return x
|
||||
@when("click", btn)
|
||||
def handler(event):
|
||||
nonlocal called
|
||||
called = True
|
||||
call_flag.set()
|
||||
|
||||
btn.click()
|
||||
await call_flag.wait()
|
||||
assert called
|
||||
|
||||
|
||||
async def test_when_with_element_collection_selector():
|
||||
"""
|
||||
The @when decorator should accept an ElementCollection as selector.
|
||||
"""
|
||||
btn1 = web.button("btn1", id="col_test_1", classes=["test-class"])
|
||||
btn2 = web.button("btn2", id="col_test_2", classes=["test-class"])
|
||||
container = get_container()
|
||||
container.append(btn1)
|
||||
container.append(btn2)
|
||||
|
||||
collection = web.page.find(".test-class")
|
||||
counter = 0
|
||||
call_flag = asyncio.Event()
|
||||
|
||||
@when("click", collection)
|
||||
def handler(event):
|
||||
nonlocal counter
|
||||
counter += 1
|
||||
if counter == 2:
|
||||
call_flag.set()
|
||||
|
||||
btn1.click()
|
||||
btn2.click()
|
||||
await call_flag.wait()
|
||||
assert counter == 2
|
||||
|
||||
|
||||
async def test_when_with_list_of_elements():
|
||||
"""
|
||||
The @when decorator should accept a list of DOM elements as selector.
|
||||
"""
|
||||
btn1 = web.button("btn1", id="list_test_1")
|
||||
btn2 = web.button("btn2", id="list_test_2")
|
||||
container = get_container()
|
||||
container.append(btn1)
|
||||
container.append(btn2)
|
||||
|
||||
elements = [btn1._dom_element, btn2._dom_element]
|
||||
counter = 0
|
||||
call_flag = asyncio.Event()
|
||||
|
||||
@when("click", elements)
|
||||
def handler(event):
|
||||
nonlocal counter
|
||||
counter += 1
|
||||
if counter == 2:
|
||||
call_flag.set()
|
||||
|
||||
btn1.click()
|
||||
btn2.click()
|
||||
await call_flag.wait()
|
||||
assert counter == 2
|
||||
|
||||
|
||||
def test_when_decorator_returns_wrapper():
|
||||
"""
|
||||
The @when decorator should return the wrapped function.
|
||||
"""
|
||||
event = Event()
|
||||
|
||||
@when(event)
|
||||
def handler(result):
|
||||
return result
|
||||
|
||||
assert callable(handler)
|
||||
|
||||
|
||||
def test_when_multiple_events_on_same_handler():
|
||||
"""
|
||||
Multiple @when decorators can be stacked on the same function.
|
||||
"""
|
||||
event1 = Event()
|
||||
event2 = Event()
|
||||
counter = 0
|
||||
|
||||
@when(event1)
|
||||
@when(event2)
|
||||
def handler(result):
|
||||
nonlocal counter
|
||||
counter += 1
|
||||
|
||||
assert counter == 0
|
||||
event1.trigger("test")
|
||||
assert counter == 1
|
||||
event2.trigger("test")
|
||||
assert counter == 2
|
||||
|
||||
|
||||
async def test_when_on_different_callables():
|
||||
"""
|
||||
The @when decorator works with various callable types.
|
||||
"""
|
||||
results = []
|
||||
|
||||
def func(x):
|
||||
results.append("func")
|
||||
|
||||
async def a_func(x):
|
||||
results.append("a_func")
|
||||
|
||||
def make_inner_func():
|
||||
# Creates a simple inner function.
|
||||
|
||||
def inner_func(x=1):
|
||||
return x
|
||||
def inner_func(x):
|
||||
results.append("inner_func")
|
||||
|
||||
return inner_func
|
||||
|
||||
|
||||
def make_inner_a_func():
|
||||
# Creates a simple async inner function.
|
||||
|
||||
async def inner_a_func(x=1):
|
||||
return x
|
||||
async def inner_a_func(x):
|
||||
results.append("inner_a_func")
|
||||
|
||||
return inner_a_func
|
||||
|
||||
|
||||
def make_closure():
|
||||
# Creates a simple closure function.
|
||||
a = 1
|
||||
|
||||
def closure_func(x=1):
|
||||
return a + x
|
||||
def closure_func(x):
|
||||
results.append(f"closure_func:{a}")
|
||||
|
||||
return closure_func
|
||||
|
||||
|
||||
def make_a_closure():
|
||||
# Creates a simple async closure function.
|
||||
a = 1
|
||||
|
||||
async def closure_a_func(x=1):
|
||||
return a + x
|
||||
async def closure_a_func(x):
|
||||
results.append(f"closure_a_func:{a}")
|
||||
|
||||
return closure_a_func
|
||||
|
||||
|
||||
inner_func = make_inner_func()
|
||||
inner_a_func = make_inner_a_func()
|
||||
cl_func = make_closure()
|
||||
cl_a_func = make_a_closure()
|
||||
|
||||
|
||||
whenable = Event()
|
||||
|
||||
# Each of these should work with the when function.
|
||||
when(whenable, func)
|
||||
when(whenable, a_func)
|
||||
when(whenable, inner_func)
|
||||
when(whenable, inner_a_func)
|
||||
when(whenable, cl_func)
|
||||
when(whenable, cl_a_func)
|
||||
# If we get here, everything worked.
|
||||
assert True
|
||||
# Each of these should work with the @when decorator.
|
||||
when(whenable)(func)
|
||||
when(whenable)(a_func)
|
||||
when(whenable)(inner_func)
|
||||
when(whenable)(inner_a_func)
|
||||
when(whenable)(cl_func)
|
||||
when(whenable)(cl_a_func)
|
||||
|
||||
# Verify no handlers have been called yet.
|
||||
assert len(results) == 0
|
||||
|
||||
# Trigger the event.
|
||||
whenable.trigger("test")
|
||||
|
||||
# Wait for async handlers to complete.
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Verify all handlers were called.
|
||||
assert len(results) == 6
|
||||
assert "func" in results
|
||||
assert "a_func" in results
|
||||
assert "inner_func" in results
|
||||
assert "inner_a_func" in results
|
||||
assert "closure_func:1" in results
|
||||
assert "closure_a_func:1" in results
|
||||
|
||||
|
||||
async def test_when_dom_event_with_options():
|
||||
"""
|
||||
Options should be passed to addEventListener for DOM events.
|
||||
"""
|
||||
click_count = 0
|
||||
call_flag = asyncio.Event()
|
||||
|
||||
@when("click", "#button-for-event-testing", once=True)
|
||||
def handle_click(event):
|
||||
nonlocal click_count
|
||||
click_count += 1
|
||||
call_flag.set()
|
||||
|
||||
btn = web.page["#button-for-event-testing"]
|
||||
btn.click()
|
||||
await call_flag.wait()
|
||||
assert click_count == 1
|
||||
|
||||
# Click again - should not increment due to once=True.
|
||||
btn.click()
|
||||
# Bit of a bodge - a brief wait to ensure no handler fires.
|
||||
await asyncio.sleep(0.01)
|
||||
assert click_count == 1
|
||||
|
||||
|
||||
async def test_when_custom_event_options_ignored():
|
||||
"""
|
||||
Options should be silently ignored for custom Event objects.
|
||||
"""
|
||||
my_event = Event()
|
||||
trigger_count = 0
|
||||
call_flag = asyncio.Event()
|
||||
|
||||
@when(my_event, once=True)
|
||||
def handler(result):
|
||||
nonlocal trigger_count
|
||||
trigger_count += 1
|
||||
if trigger_count == 2:
|
||||
call_flag.set()
|
||||
|
||||
# Should trigger multiple times despite once=True being ignored.
|
||||
my_event.trigger("first")
|
||||
my_event.trigger("second")
|
||||
await call_flag.wait()
|
||||
assert trigger_count == 2
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""
|
||||
Ensure the pyscript.test function behaves as expected.
|
||||
Tests for the fetch function and response handling.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pyscript import fetch
|
||||
|
||||
|
||||
@@ -18,6 +19,17 @@ async def test_fetch_json():
|
||||
assert data["completed"] is False
|
||||
|
||||
|
||||
async def test_fetch_json_direct():
|
||||
"""
|
||||
The fetch function should support direct method chaining for JSON.
|
||||
"""
|
||||
data = await fetch("https://jsonplaceholder.typicode.com/todos/1").json()
|
||||
assert data["userId"] == 1
|
||||
assert data["id"] == 1
|
||||
assert data["title"] == "delectus aut autem"
|
||||
assert data["completed"] is False
|
||||
|
||||
|
||||
async def test_fetch_text():
|
||||
"""
|
||||
The fetch function should return the expected text response.
|
||||
@@ -31,6 +43,17 @@ async def test_fetch_text():
|
||||
assert "1" in text
|
||||
|
||||
|
||||
async def test_fetch_text_direct():
|
||||
"""
|
||||
The fetch function should support direct method chaining for text.
|
||||
"""
|
||||
text = await fetch("https://jsonplaceholder.typicode.com/todos/1").text()
|
||||
assert "delectus aut autem" in text
|
||||
assert "completed" in text
|
||||
assert "false" in text
|
||||
assert "1" in text
|
||||
|
||||
|
||||
async def test_fetch_bytearray():
|
||||
"""
|
||||
The fetch function should return the expected bytearray response.
|
||||
@@ -38,12 +61,22 @@ async def test_fetch_bytearray():
|
||||
response = await fetch("https://jsonplaceholder.typicode.com/todos/1")
|
||||
assert response.ok
|
||||
data = await response.bytearray()
|
||||
assert isinstance(data, bytearray)
|
||||
assert b"delectus aut autem" in data
|
||||
assert b"completed" in data
|
||||
assert b"false" in data
|
||||
assert b"1" in data
|
||||
|
||||
|
||||
async def test_fetch_bytearray_direct():
|
||||
"""
|
||||
The fetch function should support direct method chaining for bytearray.
|
||||
"""
|
||||
data = await fetch("https://jsonplaceholder.typicode.com/todos/1").bytearray()
|
||||
assert isinstance(data, bytearray)
|
||||
assert b"delectus aut autem" in data
|
||||
|
||||
|
||||
async def test_fetch_array_buffer():
|
||||
"""
|
||||
The fetch function should return the expected array buffer response.
|
||||
@@ -58,26 +91,217 @@ async def test_fetch_array_buffer():
|
||||
assert b"1" in bytes_
|
||||
|
||||
|
||||
async def test_fetch_ok():
|
||||
async def test_fetch_array_buffer_direct():
|
||||
"""
|
||||
The fetch function should return a response with ok set to True for an
|
||||
existing URL.
|
||||
The fetch function should support direct method chaining for arrayBuffer.
|
||||
"""
|
||||
data = await fetch("https://jsonplaceholder.typicode.com/todos/1").arrayBuffer()
|
||||
bytes_ = bytes(data)
|
||||
assert b"delectus aut autem" in bytes_
|
||||
|
||||
|
||||
async def test_fetch_blob():
|
||||
"""
|
||||
The fetch function should return a blob response.
|
||||
"""
|
||||
response = await fetch("https://jsonplaceholder.typicode.com/todos/1")
|
||||
assert response.ok
|
||||
assert response.status == 200
|
||||
data = await response.json()
|
||||
assert data["userId"] == 1
|
||||
assert data["id"] == 1
|
||||
assert data["title"] == "delectus aut autem"
|
||||
assert data["completed"] is False
|
||||
blob = await response.blob()
|
||||
assert blob.size > 0
|
||||
assert blob.type in ["application/json", "application/json; charset=utf-8"]
|
||||
|
||||
|
||||
async def test_fetch_not_ok():
|
||||
async def test_fetch_blob_direct():
|
||||
"""
|
||||
The fetch function should return a response with ok set to False for a
|
||||
non-existent URL.
|
||||
The fetch function should support direct method chaining for blob.
|
||||
"""
|
||||
blob = await fetch("https://jsonplaceholder.typicode.com/todos/1").blob()
|
||||
assert blob.size > 0
|
||||
assert blob.type in ["application/json", "application/json; charset=utf-8"]
|
||||
|
||||
|
||||
async def test_fetch_response_ok():
|
||||
"""
|
||||
The fetch function should return a response with ok set to True for
|
||||
successful requests.
|
||||
"""
|
||||
response = await fetch("https://jsonplaceholder.typicode.com/todos/1")
|
||||
assert response.ok
|
||||
|
||||
|
||||
async def test_fetch_response_not_ok():
|
||||
"""
|
||||
The fetch function should return a response with ok set to False for
|
||||
failed requests.
|
||||
"""
|
||||
response = await fetch("https://jsonplaceholder.typicode.com/todos/1000")
|
||||
assert not response.ok
|
||||
assert response.status == 404
|
||||
|
||||
|
||||
async def test_fetch_response_status():
|
||||
"""
|
||||
The fetch function should provide access to response status code.
|
||||
"""
|
||||
response = await fetch("https://jsonplaceholder.typicode.com/todos/1")
|
||||
assert response.status == 200
|
||||
|
||||
|
||||
async def test_fetch_response_status_text():
|
||||
"""
|
||||
The fetch function should provide access to response statusText.
|
||||
Note: HTTP/2 responses often have empty statusText, so we just verify
|
||||
the property exists and is a string.
|
||||
"""
|
||||
response = await fetch("https://jsonplaceholder.typicode.com/todos/1")
|
||||
assert isinstance(response.statusText, str)
|
||||
assert response.statusText in ["", "OK"]
|
||||
|
||||
|
||||
async def test_fetch_with_post_method():
|
||||
"""
|
||||
The fetch function should support POST requests.
|
||||
"""
|
||||
response = await fetch(
|
||||
"https://jsonplaceholder.typicode.com/posts",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({"title": "foo", "body": "bar", "userId": 1}),
|
||||
)
|
||||
assert response.ok
|
||||
assert response.status == 201
|
||||
data = await response.json()
|
||||
assert data["title"] == "foo"
|
||||
assert data["body"] == "bar"
|
||||
assert data["userId"] == 1
|
||||
assert "id" in data
|
||||
|
||||
|
||||
async def test_fetch_with_put_method():
|
||||
"""
|
||||
The fetch function should support PUT requests.
|
||||
"""
|
||||
response = await fetch(
|
||||
"https://jsonplaceholder.typicode.com/posts/1",
|
||||
method="PUT",
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps(
|
||||
{"id": 1, "title": "updated", "body": "updated body", "userId": 1}
|
||||
),
|
||||
)
|
||||
assert response.ok
|
||||
assert response.status == 200
|
||||
data = await response.json()
|
||||
assert data["title"] == "updated"
|
||||
assert data["body"] == "updated body"
|
||||
|
||||
|
||||
async def test_fetch_with_delete_method():
|
||||
"""
|
||||
The fetch function should support DELETE requests.
|
||||
"""
|
||||
response = await fetch(
|
||||
"https://jsonplaceholder.typicode.com/posts/1",
|
||||
method="DELETE",
|
||||
)
|
||||
assert response.ok
|
||||
assert response.status == 200
|
||||
|
||||
|
||||
async def test_fetch_with_custom_headers():
|
||||
"""
|
||||
The fetch function should support custom headers.
|
||||
"""
|
||||
response = await fetch(
|
||||
"https://jsonplaceholder.typicode.com/todos/1",
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
assert response.ok
|
||||
data = await response.json()
|
||||
assert data["id"] == 1
|
||||
|
||||
|
||||
async def test_fetch_multiple_data_extractions():
|
||||
"""
|
||||
The fetch function could allow multiple data extractions from the same
|
||||
response when using the await pattern. This is a strange one, kept in
|
||||
for completeness. Note that browser behaviour may vary here (see inline
|
||||
comments). ;-)
|
||||
"""
|
||||
response = await fetch("https://jsonplaceholder.typicode.com/todos/1")
|
||||
|
||||
# First extraction.
|
||||
text1 = await response.text()
|
||||
assert "delectus aut autem" in text1
|
||||
# Second extraction behaviour varies by browser.
|
||||
try:
|
||||
text2 = await response.text()
|
||||
# Some browsers allow it and return empty or repeated data.
|
||||
assert text2 == "" or "delectus aut autem" in text2
|
||||
except Exception:
|
||||
# Other browsers throw an exception for already-consumed body.
|
||||
# This is expected and valid behaviour per the fetch spec.
|
||||
pass
|
||||
|
||||
|
||||
async def test_fetch_404_error_handling():
|
||||
"""
|
||||
The fetch function should handle 404 responses gracefully.
|
||||
"""
|
||||
response = await fetch("https://jsonplaceholder.typicode.com/todos/999999")
|
||||
assert not response.ok
|
||||
assert response.status == 404
|
||||
# Should still be able to extract data even from error responses.
|
||||
data = await response.json()
|
||||
assert data == {}
|
||||
|
||||
|
||||
async def test_fetch_error_response_with_text():
|
||||
"""
|
||||
Error responses should still allow text extraction.
|
||||
"""
|
||||
response = await fetch("https://jsonplaceholder.typicode.com/todos/999999")
|
||||
assert not response.ok
|
||||
text = await response.text()
|
||||
# Error responses may have empty or JSON content.
|
||||
assert isinstance(text, str)
|
||||
|
||||
|
||||
async def test_fetch_response_headers():
|
||||
"""
|
||||
The fetch function should provide access to response headers.
|
||||
"""
|
||||
response = await fetch("https://jsonplaceholder.typicode.com/todos/1")
|
||||
assert response.ok
|
||||
# Access headers through the underlying response object.
|
||||
content_type = response.headers.get("content-type")
|
||||
assert "application/json" in content_type
|
||||
|
||||
|
||||
async def test_fetch_direct_chaining_with_error():
|
||||
"""
|
||||
Direct method chaining should work even with error responses.
|
||||
"""
|
||||
data = await fetch("https://jsonplaceholder.typicode.com/todos/999999").json()
|
||||
# Should return empty dict for 404. This is expected API behaviour
|
||||
# from the jsonplaceholder API.
|
||||
assert data == {}
|
||||
|
||||
|
||||
async def test_fetch_options_passed_correctly():
|
||||
"""
|
||||
The fetch function should correctly pass options to the underlying
|
||||
JavaScript fetch.
|
||||
"""
|
||||
response = await fetch(
|
||||
"https://jsonplaceholder.typicode.com/posts",
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Custom-Header": "test-value",
|
||||
},
|
||||
body=json.dumps({"test": "data"}),
|
||||
)
|
||||
assert response.ok
|
||||
# The request succeeded, confirming options were passed correctly.
|
||||
assert response.status == 201
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""
|
||||
Exercise (as much as is possible) the pyscript.ffi namespace.
|
||||
|
||||
We assume that the underlying `create_proxy` and `to_js` functions
|
||||
work as expected (these are tested in Pyodide and MicroPython respectively).
|
||||
"""
|
||||
|
||||
import upytest
|
||||
@@ -38,3 +41,85 @@ def test_to_js():
|
||||
else:
|
||||
from pyodide.ffi import JsProxy
|
||||
assert isinstance(js_obj, JsProxy)
|
||||
|
||||
|
||||
def test_is_none_with_python_none():
|
||||
"""
|
||||
The is_none function should return True for Python None.
|
||||
"""
|
||||
assert ffi.is_none(None)
|
||||
|
||||
|
||||
def test_is_none_with_js_null():
|
||||
"""
|
||||
The is_none function should return True for JavaScript null.
|
||||
"""
|
||||
import js
|
||||
|
||||
assert ffi.is_none(ffi.jsnull)
|
||||
|
||||
|
||||
def test_is_none_with_other_values():
|
||||
"""
|
||||
The is_none function should return False for non-null false-y
|
||||
values.
|
||||
"""
|
||||
assert not ffi.is_none(0)
|
||||
assert not ffi.is_none("")
|
||||
assert not ffi.is_none(False)
|
||||
assert not ffi.is_none([])
|
||||
assert not ffi.is_none({})
|
||||
|
||||
|
||||
def test_assign_single_source():
|
||||
"""
|
||||
The assign function should merge a single source object into target.
|
||||
"""
|
||||
import js
|
||||
|
||||
target = js.Object.new()
|
||||
ffi.assign(target, {"a": 1, "b": 2})
|
||||
|
||||
assert target.a == 1
|
||||
assert target.b == 2
|
||||
|
||||
|
||||
def test_assign_multiple_sources():
|
||||
"""
|
||||
The assign function should merge multiple source objects into target.
|
||||
"""
|
||||
import js
|
||||
|
||||
target = js.Object.new()
|
||||
ffi.assign(target, {"a": 1}, {"b": 2}, {"c": 3})
|
||||
|
||||
assert target.a == 1
|
||||
assert target.b == 2
|
||||
assert target.c == 3
|
||||
|
||||
|
||||
def test_assign_overwrites_properties():
|
||||
"""
|
||||
The assign function should overwrite existing properties.
|
||||
"""
|
||||
import js
|
||||
|
||||
target = js.Object.new()
|
||||
target.a = 1
|
||||
ffi.assign(target, {"a": 2, "b": 3})
|
||||
|
||||
assert target.a == 2
|
||||
assert target.b == 3
|
||||
|
||||
|
||||
def test_assign_returns_target():
|
||||
"""
|
||||
The assign function should return the modified target object.
|
||||
"""
|
||||
import js
|
||||
|
||||
target = js.Object.new()
|
||||
result = ffi.assign(target, {"a": 1})
|
||||
|
||||
assert result is target
|
||||
assert result.a == 1
|
||||
|
||||
56
core/tests/python/tests/test_fs.py
Normal file
56
core/tests/python/tests/test_fs.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
**INCOMPLETE** tests for the pyscript.fs module.
|
||||
|
||||
Note: Full unit tests require Chromium browser and user interaction
|
||||
to grant filesystem permissions. These tests focus on validation logic and
|
||||
error handling that can be tested without permissions.
|
||||
"""
|
||||
|
||||
import upytest
|
||||
from pyscript import fs
|
||||
|
||||
|
||||
def test_mounted_dict_accessible():
|
||||
"""
|
||||
The mounted dictionary should be accessible and be a dict.
|
||||
"""
|
||||
assert hasattr(fs, "mounted")
|
||||
assert isinstance(fs.mounted, dict)
|
||||
|
||||
|
||||
def test_functions_exist():
|
||||
"""
|
||||
All public fs functions should exist and be callable.
|
||||
"""
|
||||
assert hasattr(fs, "mount")
|
||||
assert callable(fs.mount)
|
||||
assert hasattr(fs, "sync")
|
||||
assert callable(fs.sync)
|
||||
assert hasattr(fs, "unmount")
|
||||
assert callable(fs.unmount)
|
||||
assert hasattr(fs, "revoke")
|
||||
assert callable(fs.revoke)
|
||||
|
||||
|
||||
async def test_sync_unmounted_path():
|
||||
"""
|
||||
Syncing an unmounted path should raise KeyError with helpful message.
|
||||
"""
|
||||
with upytest.raises(KeyError):
|
||||
await fs.sync("/nonexistent")
|
||||
|
||||
|
||||
async def test_unmount_unmounted_path():
|
||||
"""
|
||||
Unmounting an unmounted path should raise KeyError with helpful message.
|
||||
"""
|
||||
with upytest.raises(KeyError):
|
||||
await fs.unmount("/nonexistent")
|
||||
|
||||
|
||||
def test_check_permission_function_exists():
|
||||
"""
|
||||
The internal _check_permission function should exist.
|
||||
"""
|
||||
assert hasattr(fs, "_check_permission")
|
||||
assert callable(fs._check_permission)
|
||||
@@ -1,4 +1,4 @@
|
||||
""""
|
||||
"""
|
||||
Tests for the PyScript media module.
|
||||
"""
|
||||
|
||||
@@ -7,80 +7,170 @@ import upytest
|
||||
from pyscript import media
|
||||
|
||||
|
||||
@upytest.skip(
|
||||
"Uses Pyodide-specific to_js function in MicroPython",
|
||||
skip_when=upytest.is_micropython,
|
||||
)
|
||||
async def test_device_enumeration():
|
||||
"""Test enumerating media devices."""
|
||||
async def test_list_devices_returns_list():
|
||||
"""
|
||||
The list_devices function should return a list of Device objects.
|
||||
"""
|
||||
try:
|
||||
devices = await media.list_devices()
|
||||
assert isinstance(devices, list)
|
||||
except Exception:
|
||||
# Permission denied or no devices available - test passes.
|
||||
pass
|
||||
|
||||
|
||||
async def test_device_properties():
|
||||
"""
|
||||
Device objects should have expected properties.
|
||||
"""
|
||||
try:
|
||||
devices = await media.list_devices()
|
||||
assert isinstance(devices, list), "list_devices should return a list"
|
||||
|
||||
# If devices are found, verify they have the expected functionality
|
||||
if devices:
|
||||
device = devices[0]
|
||||
|
||||
# Test real device properties exist (but don't assert on their values)
|
||||
# Browser security might restrict actual values until permissions are granted
|
||||
assert hasattr(device, "id"), "Device should have id property"
|
||||
assert hasattr(device, "kind"), "Device should have kind property"
|
||||
# Test all properties exist.
|
||||
assert hasattr(device, "id")
|
||||
assert hasattr(device, "kind")
|
||||
assert hasattr(device, "label")
|
||||
assert hasattr(device, "group")
|
||||
|
||||
# Test kind has valid value.
|
||||
assert device.kind in [
|
||||
"videoinput",
|
||||
"audioinput",
|
||||
"audiooutput",
|
||||
], f"Device should have a valid kind, got: {device.kind}"
|
||||
|
||||
# Verify dictionary access works with actual device
|
||||
assert (
|
||||
device["id"] == device.id
|
||||
), "Dictionary access should match property access"
|
||||
assert (
|
||||
device["kind"] == device.kind
|
||||
), "Dictionary access should match property access"
|
||||
|
||||
|
||||
@upytest.skip("Waiting on a bug-fix in MicroPython, for this test to work.", skip_when=upytest.is_micropython)
|
||||
async def test_video_stream_acquisition():
|
||||
"""Test video stream."""
|
||||
try:
|
||||
# Load a video stream
|
||||
stream = await media.Device.load(video=True)
|
||||
|
||||
# Verify we get a real stream with expected properties
|
||||
assert hasattr(stream, "active"), "Stream should have active property"
|
||||
|
||||
# Check for video tracks, but don't fail if permissions aren't granted
|
||||
if stream._dom_element and hasattr(stream._dom_element, "getVideoTracks"):
|
||||
tracks = stream._dom_element.getVideoTracks()
|
||||
if tracks.length > 0:
|
||||
assert True, "Video stream has video tracks"
|
||||
except Exception as e:
|
||||
# If the browser blocks access, the test should still pass
|
||||
# This is because we're testing the API works, not that permissions are granted
|
||||
assert (
|
||||
True
|
||||
), f"Stream acquisition attempted but may require permissions: {str(e)}"
|
||||
|
||||
|
||||
@upytest.skip("Waiting on a bug-fix in MicroPython, for this test to work.", skip_when=upytest.is_micropython)
|
||||
async def test_custom_video_constraints():
|
||||
"""Test loading video with custom constraints."""
|
||||
try:
|
||||
# Define custom constraints
|
||||
constraints = {"width": 640, "height": 480}
|
||||
|
||||
# Load stream with custom constraints
|
||||
stream = await media.Device.load(video=constraints)
|
||||
|
||||
# Basic stream property check
|
||||
assert hasattr(stream, "active"), "Stream should have active property"
|
||||
|
||||
# Check for tracks only if we have access
|
||||
if stream._dom_element and hasattr(stream._dom_element, "getVideoTracks"):
|
||||
tracks = stream._dom_element.getVideoTracks()
|
||||
if tracks.length > 0 and hasattr(tracks[0], "getSettings"):
|
||||
# Settings verification is optional - browsers may handle constraints differently
|
||||
]
|
||||
except Exception:
|
||||
# Permission denied or no devices available - test passes.
|
||||
pass
|
||||
|
||||
|
||||
async def test_device_dict_access():
|
||||
"""
|
||||
Device objects should support dictionary-style access for JavaScript
|
||||
interop.
|
||||
"""
|
||||
try:
|
||||
devices = await media.list_devices()
|
||||
|
||||
if devices:
|
||||
device = devices[0]
|
||||
|
||||
# Dictionary access should match property access.
|
||||
assert device["id"] == device.id
|
||||
assert device["kind"] == device.kind
|
||||
assert device["label"] == device.label
|
||||
assert device["group"] == device.group
|
||||
except Exception:
|
||||
# Permission denied or no devices available - test passes.
|
||||
pass
|
||||
|
||||
|
||||
async def test_request_stream_video_only():
|
||||
"""
|
||||
The request_stream method should return a stream for video only.
|
||||
"""
|
||||
try:
|
||||
stream = await media.Device.request_stream(video=True)
|
||||
assert hasattr(stream, "active")
|
||||
except Exception:
|
||||
# Permission denied or no devices available - test passes.
|
||||
pass
|
||||
|
||||
|
||||
async def test_request_stream_audio_only():
|
||||
"""
|
||||
The request_stream method should return a stream for audio only.
|
||||
"""
|
||||
try:
|
||||
stream = await media.Device.request_stream(audio=True, video=False)
|
||||
assert hasattr(stream, "active")
|
||||
except Exception:
|
||||
# Permission denied or no devices available - test passes.
|
||||
pass
|
||||
|
||||
|
||||
async def test_request_stream_audio_and_video():
|
||||
"""
|
||||
The request_stream method should return a stream for both audio and video.
|
||||
"""
|
||||
try:
|
||||
stream = await media.Device.request_stream(audio=True, video=True)
|
||||
assert hasattr(stream, "active")
|
||||
except Exception:
|
||||
# Permission denied or no devices available - test passes.
|
||||
pass
|
||||
|
||||
|
||||
async def test_request_stream_with_constraints():
|
||||
"""
|
||||
The request_stream method should accept video constraints as a dict.
|
||||
"""
|
||||
try:
|
||||
constraints = {"width": 640, "height": 480}
|
||||
stream = await media.Device.request_stream(video=constraints)
|
||||
assert hasattr(stream, "active")
|
||||
except Exception:
|
||||
# Permission denied or no devices available - test passes.
|
||||
pass
|
||||
|
||||
|
||||
async def test_load_backwards_compatibility():
|
||||
"""
|
||||
The deprecated load method should still work for backwards compatibility.
|
||||
"""
|
||||
try:
|
||||
stream = await media.Device.load(video=True)
|
||||
assert hasattr(stream, "active")
|
||||
except Exception:
|
||||
# Permission denied or no devices available - test passes.
|
||||
pass
|
||||
|
||||
|
||||
@upytest.skip(
|
||||
"Blocks on Pyodide due to permission dialog.",
|
||||
skip_when=not upytest.is_micropython,
|
||||
)
|
||||
async def test_device_get_stream():
|
||||
"""
|
||||
The get_stream instance method should return a stream from a specific
|
||||
device.
|
||||
"""
|
||||
try:
|
||||
devices = await media.list_devices()
|
||||
|
||||
# Find a video input device to test with.
|
||||
video_devices = [d for d in devices if d.kind == "videoinput"]
|
||||
|
||||
if video_devices:
|
||||
stream = await video_devices[0].get_stream()
|
||||
assert hasattr(stream, "active")
|
||||
except Exception:
|
||||
# Permission denied or no devices available - test passes.
|
||||
pass
|
||||
|
||||
|
||||
async def test_device_filtering_by_kind():
|
||||
"""
|
||||
Devices should be filterable by their kind property.
|
||||
"""
|
||||
try:
|
||||
devices = await media.list_devices()
|
||||
|
||||
video_inputs = [d for d in devices if d.kind == "videoinput"]
|
||||
audio_inputs = [d for d in devices if d.kind == "audioinput"]
|
||||
audio_outputs = [d for d in devices if d.kind == "audiooutput"]
|
||||
|
||||
# All filtered devices should have correct kind.
|
||||
for device in video_inputs:
|
||||
assert device.kind == "videoinput"
|
||||
|
||||
for device in audio_inputs:
|
||||
assert device.kind == "audioinput"
|
||||
|
||||
for device in audio_outputs:
|
||||
assert device.kind == "audiooutput"
|
||||
except Exception:
|
||||
# Permission denied or no devices available - test passes.
|
||||
pass
|
||||
except Exception as e:
|
||||
# If the browser blocks access, test that the API structure works
|
||||
assert True, f"Custom constraint test attempted: {str(e)}"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Ensure the pyscript.storage object behaves as a Python dict.
|
||||
Tests for the pyscript.storage module.
|
||||
"""
|
||||
|
||||
from pyscript import Storage, storage
|
||||
@@ -8,6 +8,9 @@ test_store = None
|
||||
|
||||
|
||||
async def setup():
|
||||
"""
|
||||
Set up a clean test storage before each test.
|
||||
"""
|
||||
global test_store
|
||||
if test_store is None:
|
||||
test_store = await storage("test_store")
|
||||
@@ -16,6 +19,9 @@ async def setup():
|
||||
|
||||
|
||||
async def teardown():
|
||||
"""
|
||||
Clean up test storage after each test.
|
||||
"""
|
||||
if test_store:
|
||||
test_store.clear()
|
||||
await test_store.sync()
|
||||
@@ -25,17 +31,17 @@ async def test_storage_as_dict():
|
||||
"""
|
||||
The storage object should behave as a Python dict.
|
||||
"""
|
||||
# Assign
|
||||
# Assign.
|
||||
test_store["a"] = 1
|
||||
# Retrieve
|
||||
# Retrieve.
|
||||
assert test_store["a"] == 1
|
||||
assert "a" in test_store
|
||||
assert len(test_store) == 1
|
||||
# Iterate
|
||||
# Iterate.
|
||||
for k, v in test_store.items():
|
||||
assert k == "a"
|
||||
assert v == 1
|
||||
# Remove
|
||||
# Remove.
|
||||
del test_store["a"]
|
||||
assert "a" not in test_store
|
||||
assert len(test_store) == 0
|
||||
@@ -86,3 +92,233 @@ async def test_storage_clear():
|
||||
assert len(test_store) == 2
|
||||
test_store.clear()
|
||||
assert len(test_store) == 0
|
||||
|
||||
|
||||
async def test_storage_get_method():
|
||||
"""
|
||||
The get method should return default value for missing keys.
|
||||
"""
|
||||
test_store["exists"] = "value"
|
||||
|
||||
assert test_store.get("exists") == "value"
|
||||
assert test_store.get("missing") is None
|
||||
assert test_store.get("missing", "default") == "default"
|
||||
|
||||
|
||||
async def test_storage_keys_values_items():
|
||||
"""
|
||||
The keys, values, and items methods should work like dict.
|
||||
"""
|
||||
test_store["a"] = 1
|
||||
test_store["b"] = 2
|
||||
test_store["c"] = 3
|
||||
|
||||
assert set(test_store.keys()) == {"a", "b", "c"}
|
||||
assert set(test_store.values()) == {1, 2, 3}
|
||||
assert set(test_store.items()) == {("a", 1), ("b", 2), ("c", 3)}
|
||||
|
||||
|
||||
async def test_storage_update():
|
||||
"""
|
||||
The update method should add multiple items at once.
|
||||
"""
|
||||
test_store["a"] = 1
|
||||
|
||||
# Update with dict.
|
||||
test_store.update({"b": 2, "c": 3})
|
||||
assert test_store["b"] == 2
|
||||
assert test_store["c"] == 3
|
||||
|
||||
# Update with keyword arguments.
|
||||
test_store.update(d=4, e=5)
|
||||
assert test_store["d"] == 4
|
||||
assert test_store["e"] == 5
|
||||
|
||||
|
||||
async def test_storage_pop():
|
||||
"""
|
||||
The pop method should remove and return values.
|
||||
"""
|
||||
test_store["a"] = 1
|
||||
test_store["b"] = 2
|
||||
|
||||
value = test_store.pop("a")
|
||||
assert value == 1
|
||||
assert "a" not in test_store
|
||||
assert len(test_store) == 1
|
||||
|
||||
# Pop with default.
|
||||
value = test_store.pop("missing", "default")
|
||||
assert value == "default"
|
||||
|
||||
|
||||
async def test_storage_persistence():
|
||||
"""
|
||||
Data should persist after sync and reload.
|
||||
"""
|
||||
test_store["persistent"] = "value"
|
||||
await test_store.sync()
|
||||
|
||||
# Reload the same storage.
|
||||
reloaded = await storage("test_store")
|
||||
assert reloaded["persistent"] == "value"
|
||||
|
||||
|
||||
async def test_storage_nested_structures():
|
||||
"""
|
||||
Nested data structures should be stored and retrieved correctly.
|
||||
"""
|
||||
nested = {
|
||||
"level1": {"level2": {"level3": [1, 2, 3]}},
|
||||
"list_of_dicts": [{"a": 1}, {"b": 2}, {"c": 3}],
|
||||
}
|
||||
|
||||
test_store["nested"] = nested
|
||||
await test_store.sync()
|
||||
|
||||
retrieved = test_store["nested"]
|
||||
assert retrieved["level1"]["level2"]["level3"] == [1, 2, 3]
|
||||
assert retrieved["list_of_dicts"][0]["a"] == 1
|
||||
|
||||
|
||||
async def test_storage_overwrite():
|
||||
"""
|
||||
Overwriting values should work correctly.
|
||||
"""
|
||||
test_store["key"] = "original"
|
||||
assert test_store["key"] == "original"
|
||||
|
||||
test_store["key"] = "updated"
|
||||
assert test_store["key"] == "updated"
|
||||
|
||||
|
||||
async def test_storage_empty_string_key():
|
||||
"""
|
||||
Empty strings should be valid keys.
|
||||
"""
|
||||
test_store[""] = "empty key"
|
||||
assert "" in test_store
|
||||
assert test_store[""] == "empty key"
|
||||
|
||||
|
||||
async def test_storage_special_characters_in_keys():
|
||||
"""
|
||||
Keys with special characters should work.
|
||||
"""
|
||||
test_store["key with spaces"] = 1
|
||||
test_store["key-with-dashes"] = 2
|
||||
test_store["key.with.dots"] = 3
|
||||
test_store["key/with/slashes"] = 4
|
||||
|
||||
assert test_store["key with spaces"] == 1
|
||||
assert test_store["key-with-dashes"] == 2
|
||||
assert test_store["key.with.dots"] == 3
|
||||
assert test_store["key/with/slashes"] == 4
|
||||
|
||||
|
||||
async def test_storage_multiple_stores():
|
||||
"""
|
||||
Multiple named storages should be independent.
|
||||
"""
|
||||
store1 = await storage("store1")
|
||||
store2 = await storage("store2")
|
||||
|
||||
store1.clear()
|
||||
store2.clear()
|
||||
|
||||
store1["key"] = "value1"
|
||||
store2["key"] = "value2"
|
||||
|
||||
assert store1["key"] == "value1"
|
||||
assert store2["key"] == "value2"
|
||||
|
||||
# Clean up.
|
||||
store1.clear()
|
||||
store2.clear()
|
||||
await store1.sync()
|
||||
await store2.sync()
|
||||
|
||||
|
||||
async def test_storage_empty_name_raises():
|
||||
"""
|
||||
Creating storage with empty name should raise ValueError.
|
||||
"""
|
||||
try:
|
||||
await storage("")
|
||||
assert False, "Should have raised ValueError"
|
||||
except ValueError as e:
|
||||
assert "non-empty" in str(e)
|
||||
|
||||
|
||||
async def test_custom_storage_class():
|
||||
"""
|
||||
Custom Storage subclasses should work correctly.
|
||||
"""
|
||||
calls = []
|
||||
|
||||
class TrackingStorage(Storage):
|
||||
def __setitem__(self, key, value):
|
||||
calls.append(("set", key, value))
|
||||
super().__setitem__(key, value)
|
||||
|
||||
def __delitem__(self, key):
|
||||
calls.append(("del", key))
|
||||
super().__delitem__(key)
|
||||
|
||||
custom_store = await storage("custom_test", storage_class=TrackingStorage)
|
||||
custom_store.clear()
|
||||
calls.clear()
|
||||
|
||||
# Test setitem tracking.
|
||||
custom_store["test"] = 123
|
||||
assert ("set", "test", 123) in calls
|
||||
assert custom_store["test"] == 123
|
||||
|
||||
# Test delitem tracking.
|
||||
del custom_store["test"]
|
||||
assert ("del", "test") in calls
|
||||
|
||||
# Clean up.
|
||||
custom_store.clear()
|
||||
await custom_store.sync()
|
||||
|
||||
|
||||
async def test_storage_boolean_false_vs_none():
|
||||
"""
|
||||
False and None should be distinguishable.
|
||||
"""
|
||||
test_store["false"] = False
|
||||
test_store["none"] = None
|
||||
|
||||
assert test_store["false"] is False
|
||||
assert test_store["false"] is not None
|
||||
assert test_store["none"] is None
|
||||
assert test_store["none"] is not False
|
||||
|
||||
|
||||
async def test_storage_numeric_zero_vs_none():
|
||||
"""
|
||||
Zero and None should be distinguishable.
|
||||
"""
|
||||
test_store["zero_int"] = 0
|
||||
test_store["zero_float"] = 0.0
|
||||
test_store["none"] = None
|
||||
|
||||
assert test_store["zero_int"] == 0
|
||||
assert test_store["zero_int"] is not None
|
||||
assert test_store["zero_float"] == 0.0
|
||||
assert test_store["zero_float"] is not None
|
||||
assert test_store["none"] is None
|
||||
|
||||
|
||||
async def test_storage_empty_collections():
|
||||
"""
|
||||
Empty lists and dicts should be stored correctly.
|
||||
"""
|
||||
test_store["empty_list"] = []
|
||||
test_store["empty_dict"] = {}
|
||||
|
||||
assert test_store["empty_list"] == []
|
||||
assert isinstance(test_store["empty_list"], list)
|
||||
assert test_store["empty_dict"] == {}
|
||||
assert isinstance(test_store["empty_dict"], dict)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
"""
|
||||
Tests for the pyscript.util module.
|
||||
"""
|
||||
|
||||
import upytest
|
||||
import js
|
||||
from pyscript import util
|
||||
@@ -5,8 +9,8 @@ from pyscript import util
|
||||
|
||||
def test_as_bytearray():
|
||||
"""
|
||||
Test the as_bytearray function correctly converts a JavaScript ArrayBuffer
|
||||
to a Python bytearray.
|
||||
The as_bytearray function should convert a JavaScript ArrayBuffer to a
|
||||
Python bytearray.
|
||||
"""
|
||||
msg = b"Hello, world!"
|
||||
buffer = js.ArrayBuffer.new(len(msg))
|
||||
@@ -18,31 +22,187 @@ def test_as_bytearray():
|
||||
assert ba == msg
|
||||
|
||||
|
||||
def test_not_supported():
|
||||
def test_as_bytearray_empty():
|
||||
"""
|
||||
Test the NotSupported class raises an exception when trying to access
|
||||
attributes or call the object.
|
||||
The as_bytearray function should handle empty ArrayBuffers.
|
||||
"""
|
||||
buffer = js.ArrayBuffer.new(0)
|
||||
ba = util.as_bytearray(buffer)
|
||||
assert isinstance(ba, bytearray)
|
||||
assert len(ba) == 0
|
||||
|
||||
|
||||
def test_as_bytearray_binary_data():
|
||||
"""
|
||||
The as_bytearray function should handle binary data with all byte values.
|
||||
"""
|
||||
# Test with all possible byte values.
|
||||
data = bytes(range(256))
|
||||
buffer = js.ArrayBuffer.new(len(data))
|
||||
ui8a = js.Uint8Array.new(buffer)
|
||||
for i, b in enumerate(data):
|
||||
ui8a[i] = b
|
||||
ba = util.as_bytearray(buffer)
|
||||
assert ba == bytearray(data)
|
||||
|
||||
|
||||
def test_not_supported_repr():
|
||||
"""
|
||||
The NotSupported class should have a meaningful repr.
|
||||
"""
|
||||
ns = util.NotSupported("test_feature", "Feature not available")
|
||||
repr_str = repr(ns)
|
||||
assert "NotSupported" in repr_str
|
||||
assert "test_feature" in repr_str
|
||||
|
||||
|
||||
def test_not_supported_getattr():
|
||||
"""
|
||||
The NotSupported class should raise AttributeError on attribute access.
|
||||
"""
|
||||
ns = util.NotSupported("test", "This is not supported.")
|
||||
with upytest.raises(AttributeError) as e:
|
||||
ns.test
|
||||
assert str(e.exception) == "This is not supported.", str(e.exception)
|
||||
ns.some_attr
|
||||
assert str(e.exception) == "This is not supported."
|
||||
|
||||
|
||||
def test_not_supported_setattr():
|
||||
"""
|
||||
The NotSupported class should raise AttributeError on attribute
|
||||
assignment.
|
||||
"""
|
||||
ns = util.NotSupported("test", "This is not supported.")
|
||||
with upytest.raises(AttributeError) as e:
|
||||
ns.test = 1
|
||||
assert str(e.exception) == "This is not supported.", str(e.exception)
|
||||
ns.some_attr = 1
|
||||
assert str(e.exception) == "This is not supported."
|
||||
|
||||
|
||||
def test_not_supported_call():
|
||||
"""
|
||||
The NotSupported class should raise TypeError when called.
|
||||
"""
|
||||
ns = util.NotSupported("test", "This is not supported.")
|
||||
with upytest.raises(TypeError) as e:
|
||||
ns()
|
||||
assert str(e.exception) == "This is not supported.", str(e.exception)
|
||||
assert str(e.exception) == "This is not supported."
|
||||
|
||||
|
||||
def test_is_awaitable():
|
||||
def test_not_supported_call_with_args():
|
||||
"""
|
||||
Test the is_awaitable function correctly identifies an asynchronous
|
||||
function.
|
||||
The NotSupported class should raise TypeError when called with arguments.
|
||||
"""
|
||||
ns = util.NotSupported("test", "This is not supported.")
|
||||
with upytest.raises(TypeError) as e:
|
||||
ns(1, 2, 3)
|
||||
assert str(e.exception) == "This is not supported."
|
||||
|
||||
|
||||
def test_is_awaitable_async_function():
|
||||
"""
|
||||
The is_awaitable function should identify async functions as awaitable.
|
||||
"""
|
||||
|
||||
async def async_func():
|
||||
yield
|
||||
pass
|
||||
|
||||
assert util.is_awaitable(async_func)
|
||||
|
||||
|
||||
def test_is_awaitable_regular_function():
|
||||
"""
|
||||
The is_awaitable function should identify regular functions as not
|
||||
awaitable.
|
||||
"""
|
||||
|
||||
def regular_func():
|
||||
pass
|
||||
|
||||
assert not util.is_awaitable(regular_func)
|
||||
|
||||
|
||||
def test_is_awaitable_lambda():
|
||||
"""
|
||||
The is_awaitable function should identify lambdas as not awaitable.
|
||||
"""
|
||||
assert not util.is_awaitable(lambda: None)
|
||||
|
||||
|
||||
def test_is_awaitable_async_lambda():
|
||||
"""
|
||||
The is_awaitable function should identify async lambdas as awaitable.
|
||||
"""
|
||||
# Note: async lambdas don't exist in Python, but this documents the
|
||||
# expected behavior.
|
||||
async_lambda = lambda: (yield)
|
||||
# This test documents current behavior - may vary by implementation.
|
||||
|
||||
|
||||
def test_is_awaitable_generator():
|
||||
"""
|
||||
The is_awaitable function should handle generator functions correctly.
|
||||
"""
|
||||
|
||||
def gen_func():
|
||||
yield 1
|
||||
|
||||
# Generator functions are treated differently in MicroPython vs Pyodide.
|
||||
# In MicroPython, async functions are generator functions.
|
||||
result = util.is_awaitable(gen_func)
|
||||
# Result depends on Python implementation.
|
||||
assert isinstance(result, bool)
|
||||
|
||||
|
||||
def test_is_awaitable_async_closure():
|
||||
"""
|
||||
The is_awaitable function should handle async closures correctly.
|
||||
"""
|
||||
|
||||
def make_async_closure():
|
||||
async def inner():
|
||||
pass
|
||||
|
||||
return inner
|
||||
|
||||
closure = make_async_closure()
|
||||
assert util.is_awaitable(closure)
|
||||
|
||||
|
||||
def test_is_awaitable_regular_closure():
|
||||
"""
|
||||
The is_awaitable function should handle regular closures correctly.
|
||||
"""
|
||||
|
||||
def make_closure():
|
||||
def inner():
|
||||
pass
|
||||
|
||||
return inner
|
||||
|
||||
closure = make_closure()
|
||||
assert not util.is_awaitable(closure)
|
||||
|
||||
|
||||
def test_is_awaitable_builtin():
|
||||
"""
|
||||
The is_awaitable function should identify built-in functions as not
|
||||
awaitable.
|
||||
"""
|
||||
assert not util.is_awaitable(print)
|
||||
assert not util.is_awaitable(len)
|
||||
|
||||
|
||||
def test_is_awaitable_class_method():
|
||||
"""
|
||||
The is_awaitable function should handle class methods correctly.
|
||||
"""
|
||||
|
||||
class TestClass:
|
||||
async def async_method(self):
|
||||
pass
|
||||
|
||||
def sync_method(self):
|
||||
pass
|
||||
|
||||
obj = TestClass()
|
||||
assert util.is_awaitable(obj.async_method)
|
||||
assert not util.is_awaitable(obj.sync_method)
|
||||
|
||||
@@ -6,6 +6,7 @@ import asyncio
|
||||
|
||||
import upytest
|
||||
from pyscript import RUNNING_IN_WORKER, document, web, when
|
||||
from pyscript.ffi import to_js
|
||||
|
||||
|
||||
def setup():
|
||||
@@ -22,14 +23,22 @@ def test_getitem_by_id():
|
||||
"""
|
||||
An element with an id in the DOM can be retrieved by id.
|
||||
"""
|
||||
result = web.page.find("#div-no-classes")
|
||||
# There is a single result.
|
||||
assert len(result) == 1
|
||||
result = web.page["#div-no-classes"]
|
||||
# The result is a div.
|
||||
assert result[0].get_tag_name() == "div"
|
||||
assert result.get_tag_name() == "div"
|
||||
# The result has the expected id.
|
||||
assert result.id == "div-no-classes"
|
||||
# Now do the same but without the '#'
|
||||
result = web.page["div-no-classes"]
|
||||
assert result.get_tag_name() == "div"
|
||||
assert result.id == "div-no-classes"
|
||||
|
||||
|
||||
def test_getitem_by_class():
|
||||
def test_find_item_by_class():
|
||||
"""
|
||||
Elements with a given class in the DOM can be retrieved by class via the
|
||||
find method.
|
||||
"""
|
||||
ids = [
|
||||
"test_class_selector",
|
||||
"test_selector_w_children",
|
||||
@@ -37,35 +46,188 @@ def test_getitem_by_class():
|
||||
]
|
||||
expected_class = "a-test-class"
|
||||
result = web.page.find(f".{expected_class}")
|
||||
|
||||
# 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 with a given class in the DOM can be retrieved by class via the
|
||||
find method. They can be bulk updated via the update_all method.
|
||||
"""
|
||||
elements = web.page.find(".multi-elems")
|
||||
|
||||
for element in elements:
|
||||
assert element.innerHTML == f"Content {element.id.replace('#', '')}"
|
||||
|
||||
new_content = "New Content"
|
||||
elements.innerHTML = new_content
|
||||
elements.update_all(innerHTML=new_content)
|
||||
for element in elements:
|
||||
assert element.innerHTML == new_content
|
||||
|
||||
|
||||
class TestHelperFunctions:
|
||||
"""
|
||||
Test the internal helper functions.
|
||||
"""
|
||||
|
||||
def test_wrap_if_not_none_with_element(self):
|
||||
"""
|
||||
Test wrapping a valid DOM element.
|
||||
"""
|
||||
|
||||
# Create a DOM element.
|
||||
dom_div = document.createElement("div")
|
||||
dom_div.id = "test-wrap"
|
||||
|
||||
# Wrap it.
|
||||
result = web._wrap_if_not_none(dom_div)
|
||||
|
||||
# Should return an Element instance.
|
||||
assert isinstance(result, web.Element)
|
||||
assert result.id == "test-wrap"
|
||||
|
||||
def test_wrap_if_not_none_with_none(self):
|
||||
"""
|
||||
Test wrapping None returns None.
|
||||
"""
|
||||
|
||||
# Pass None (as a JS null).
|
||||
result = web._wrap_if_not_none(to_js(None))
|
||||
|
||||
# Should return None.
|
||||
assert result is None
|
||||
|
||||
def test_find_by_id_without_hash(self):
|
||||
"""
|
||||
Test finding element by id without # prefix.
|
||||
"""
|
||||
|
||||
# Create and append an element with an id.
|
||||
test_div = web.div("Test content", id="helper-test-id")
|
||||
web.page.body.append(test_div)
|
||||
|
||||
# Find it using the helper.
|
||||
result = web._find_by_id(document, "helper-test-id")
|
||||
|
||||
# Should find the element.
|
||||
assert result is not None
|
||||
assert result.id == "helper-test-id"
|
||||
assert result.innerHTML == "Test content"
|
||||
|
||||
def test_find_by_id_with_hash(self):
|
||||
"""
|
||||
Test finding element by id with # prefix.
|
||||
"""
|
||||
|
||||
# Create and append an element with an id.
|
||||
test_div = web.div("Test content", id="helper-test-id-hash")
|
||||
web.page.body.append(test_div)
|
||||
|
||||
# Find it using the helper with # prefix.
|
||||
result = web._find_by_id(document, "#helper-test-id-hash")
|
||||
|
||||
# Should find the element.
|
||||
assert result is not None
|
||||
assert result.id == "helper-test-id-hash"
|
||||
|
||||
def test_find_by_id_not_found(self):
|
||||
"""
|
||||
Test finding non-existent id returns None.
|
||||
"""
|
||||
|
||||
# Try to find non-existent element.
|
||||
result = web._find_by_id(document, "this-id-does-not-exist")
|
||||
|
||||
# Should return None.
|
||||
assert result is None
|
||||
|
||||
def test_find_by_id_within_element(self):
|
||||
"""
|
||||
Test finding by id within a specific element.
|
||||
"""
|
||||
|
||||
# Create a container with a child.
|
||||
child = web.p("Child", id="child-in-container")
|
||||
container = web.div(child, id="container-for-search")
|
||||
web.page.body.append(container)
|
||||
|
||||
# Find the child within the container.
|
||||
result = web._find_by_id(container._dom_element, "child-in-container")
|
||||
|
||||
# Should find the child.
|
||||
assert result is not None
|
||||
assert result.id == "child-in-container"
|
||||
|
||||
def test_find_and_wrap_with_results(self):
|
||||
"""
|
||||
Test finding elements by selector.
|
||||
"""
|
||||
|
||||
# Create multiple elements with same class.
|
||||
container = web.div(
|
||||
web.p("Para 1", classes=["test-para"]),
|
||||
web.p("Para 2", classes=["test-para"]),
|
||||
web.p("Para 3", classes=["test-para"]),
|
||||
id="find-wrap-container",
|
||||
)
|
||||
web.page.body.append(container)
|
||||
|
||||
# Find them using the helper.
|
||||
result = web._find_and_wrap(container._dom_element, ".test-para")
|
||||
|
||||
# Should return an ElementCollection.
|
||||
assert isinstance(result, web.ElementCollection)
|
||||
assert len(result) == 3
|
||||
assert result[0].innerHTML == "Para 1"
|
||||
assert result[1].innerHTML == "Para 2"
|
||||
assert result[2].innerHTML == "Para 3"
|
||||
|
||||
def test_find_and_wrap_no_results(self):
|
||||
"""
|
||||
Test finding with selector that matches nothing.
|
||||
"""
|
||||
|
||||
# Create a container.
|
||||
container = web.div(id="empty-container")
|
||||
web.page.body.append(container)
|
||||
|
||||
# Find with selector that matches nothing.
|
||||
result = web._find_and_wrap(container._dom_element, ".does-not-exist")
|
||||
|
||||
# Should return empty collection.
|
||||
assert isinstance(result, web.ElementCollection)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_find_and_wrap_on_document(self):
|
||||
"""
|
||||
Test finding elements in entire document.
|
||||
"""
|
||||
|
||||
# Create elements in the document.
|
||||
web.page.body.append(web.span("Span 1", classes=["doc-span"]))
|
||||
web.page.body.append(web.span("Span 2", classes=["doc-span"]))
|
||||
|
||||
# Find them in the entire document.
|
||||
result = web._find_and_wrap(document, ".doc-span")
|
||||
|
||||
# Should find both.
|
||||
assert isinstance(result, web.ElementCollection)
|
||||
assert len(result) >= 2 # May have others from previous tests.
|
||||
|
||||
|
||||
class TestElement:
|
||||
"""
|
||||
Test the base Element class functionality.
|
||||
"""
|
||||
|
||||
def test_query(self):
|
||||
# GIVEN an existing element on the page, with at least 1 child element
|
||||
id_ = "test_selector_w_children"
|
||||
parent_div = web.page.find(f"#{id_}")[0]
|
||||
parent_div = web.page[f"#{id_}"]
|
||||
|
||||
# EXPECT it to be able to query for the first child element
|
||||
div = parent_div.find("div")[0]
|
||||
div = parent_div[0]
|
||||
|
||||
# EXPECT the new element to be associated with the parent
|
||||
assert (
|
||||
@@ -101,7 +263,7 @@ class TestElement:
|
||||
|
||||
def test_append_element(self):
|
||||
id_ = "element-append-tests"
|
||||
div = web.page.find(f"#{id_}")[0]
|
||||
div = web.page[f"#{id_}"]
|
||||
len_children_before = len(div.children)
|
||||
new_el = web.p("new element")
|
||||
div.append(new_el)
|
||||
@@ -110,7 +272,7 @@ class TestElement:
|
||||
|
||||
def test_append_dom_element_element(self):
|
||||
id_ = "element-append-tests"
|
||||
div = web.page.find(f"#{id_}")[0]
|
||||
div = web.page[f"#{id_}"]
|
||||
len_children_before = len(div.children)
|
||||
new_el = web.p("new element")
|
||||
div.append(new_el._dom_element)
|
||||
@@ -119,7 +281,7 @@ class TestElement:
|
||||
|
||||
def test_append_collection(self):
|
||||
id_ = "element-append-tests"
|
||||
div = web.page.find(f"#{id_}")[0]
|
||||
div = web.page[f"#{id_}"]
|
||||
len_children_before = len(div.children)
|
||||
collection = web.page.find(".collection")
|
||||
div.append(collection)
|
||||
@@ -131,19 +293,24 @@ class TestElement:
|
||||
def test_read_classes(self):
|
||||
id_ = "test_class_selector"
|
||||
expected_class = "a-test-class"
|
||||
div = web.page.find(f"#{id_}")[0]
|
||||
assert div.classes == [expected_class]
|
||||
div = web.page[f"#{id_}"]
|
||||
assert div.classes == {expected_class}
|
||||
|
||||
def test_add_remove_class(self):
|
||||
id_ = "div-no-classes"
|
||||
classname = "tester-class"
|
||||
div = web.page.find(f"#{id_}")[0]
|
||||
div = web.page[f"#{id_}"]
|
||||
assert not div.classes
|
||||
div.classes.add(classname)
|
||||
same_div = web.page.find(f"#{id_}")[0]
|
||||
assert div.classes == [classname] == same_div.classes
|
||||
assert div.classes == {classname}
|
||||
div.classes.remove(classname)
|
||||
assert div.classes == [] == same_div.classes
|
||||
assert div.classes == set()
|
||||
# Handle multiple classes in a single string
|
||||
multiple_classes = "class1 class2 class3"
|
||||
div.classes.add(multiple_classes)
|
||||
assert div.classes == {"class1", "class2", "class3"}
|
||||
div.classes.remove("class2 class3")
|
||||
assert div.classes == {"class1"}
|
||||
|
||||
async def test_when_decorator(self):
|
||||
called = False
|
||||
@@ -243,6 +410,129 @@ class TestElement:
|
||||
)
|
||||
assert div.textContent == div._dom_element.textContent == "<b>New Content</b>"
|
||||
|
||||
def test_update_classes(self):
|
||||
"""Test updating classes via update()."""
|
||||
div = web.div()
|
||||
div.update(classes=["foo", "bar"])
|
||||
assert "foo" in div.classes
|
||||
assert "bar" in div.classes
|
||||
|
||||
def test_update_single_class(self):
|
||||
"""Test updating single class string via update()."""
|
||||
div = web.div()
|
||||
div.update(classes="foo")
|
||||
assert "foo" in div.classes
|
||||
|
||||
def test_update_style(self):
|
||||
"""Test updating styles via update()."""
|
||||
div = web.div()
|
||||
div.update(style={"color": "red", "font-size": "16px"})
|
||||
assert div.style["color"] == "red", div.style["color"]
|
||||
assert div.style["font-size"] == "16px"
|
||||
|
||||
def test_update_attributes(self):
|
||||
"""Test updating attributes via update()."""
|
||||
div = web.div()
|
||||
div.update(id="test-id", title="Test Title")
|
||||
assert div.id == "test-id"
|
||||
assert div.title == "Test Title"
|
||||
|
||||
def test_update_combined(self):
|
||||
"""Test updating classes, styles, and attributes together."""
|
||||
div = web.div()
|
||||
div.update(classes=["foo"], style={"color": "red"}, id="test-id")
|
||||
assert "foo" in div.classes
|
||||
assert div.style["color"] == "red"
|
||||
assert div.id == "test-id"
|
||||
|
||||
def test_getitem_integer_index(self):
|
||||
"""Test indexing children by integer."""
|
||||
parent = web.div(web.p("Child 1"), web.p("Child 2"))
|
||||
assert parent[0].innerHTML == "Child 1"
|
||||
assert parent[1].innerHTML == "Child 2"
|
||||
|
||||
def test_getitem_slice(self):
|
||||
"""Test slicing children."""
|
||||
parent = web.div(web.p("Child 1"), web.p("Child 2"), web.p("Child 3"))
|
||||
sliced = parent[0:2]
|
||||
assert len(sliced) == 2
|
||||
assert sliced[0].innerHTML == "Child 1"
|
||||
assert sliced[1].innerHTML == "Child 2"
|
||||
|
||||
def test_getitem_by_id(self):
|
||||
"""Test looking up descendant by id."""
|
||||
child = web.p("Child", id="child-id")
|
||||
parent = web.div(child)
|
||||
web.page.body.append(parent)
|
||||
|
||||
result = parent["child-id"]
|
||||
assert result is not None
|
||||
assert result.id == "child-id"
|
||||
|
||||
def test_getitem_by_id_with_hash(self):
|
||||
"""Test looking up descendant by id with # prefix."""
|
||||
child = web.p("Child", id="child-id-2")
|
||||
parent = web.div(child)
|
||||
web.page.body.append(parent)
|
||||
|
||||
result = parent["#child-id-2"]
|
||||
assert result is not None
|
||||
assert result.id == "child-id-2"
|
||||
|
||||
def test_clone_basic(self):
|
||||
"""Test cloning an element."""
|
||||
original = web.div("Content", id="original")
|
||||
original.classes.add("test-class")
|
||||
|
||||
clone = original.clone()
|
||||
assert clone.innerHTML == original.innerHTML
|
||||
assert "test-class" in clone.classes
|
||||
assert clone is not original
|
||||
assert clone._dom_element is not original._dom_element
|
||||
|
||||
def test_clone_with_id(self):
|
||||
"""Test cloning with new id."""
|
||||
original = web.div("Content", id="original")
|
||||
clone = original.clone(clone_id="cloned")
|
||||
assert clone.id == "cloned"
|
||||
assert original.id == "original"
|
||||
|
||||
def test_for_attribute(self):
|
||||
"""Test that for_ maps to htmlFor."""
|
||||
label = web.label("Test", for_="input-id")
|
||||
assert label.for_ == "input-id"
|
||||
assert 'for="input-id"' in label.outerHTML
|
||||
|
||||
def test_trailing_underscore_removal(self):
|
||||
"""Test that trailing underscores are removed."""
|
||||
div = web.div()
|
||||
div.class_ = "test-class"
|
||||
# Setting class_ should set the className, which affects classes
|
||||
assert "test-class" in div.classes
|
||||
assert div.className == "test-class"
|
||||
|
||||
|
||||
class TestContainerElement:
|
||||
"""Test ContainerElement specific functionality."""
|
||||
|
||||
def test_container_iteration(self):
|
||||
"""Test iterating over container's children."""
|
||||
parent = web.div(web.p("1"), web.p("2"), web.p("3"))
|
||||
children = list(parent)
|
||||
assert len(children) == 3
|
||||
assert children[0].innerHTML == "1"
|
||||
|
||||
def test_container_children_kwarg(self):
|
||||
"""Test creating container with children kwarg."""
|
||||
parent = web.div(children=[web.p("1"), web.p("2")])
|
||||
assert len(parent.children) == 2
|
||||
|
||||
def test_container_html_string(self):
|
||||
"""Test inserting HTML string as child."""
|
||||
parent = web.div("<b>Bold text</b>")
|
||||
assert "Bold text" in parent.innerHTML
|
||||
assert "<b>" in parent.innerHTML
|
||||
|
||||
|
||||
class TestCollection:
|
||||
|
||||
@@ -260,24 +550,6 @@ class TestCollection:
|
||||
assert el == elements[i]
|
||||
assert elements[:] == elements
|
||||
|
||||
def test_style_rule(self):
|
||||
selector = ".multi-elems"
|
||||
elements = web.page.find(selector)
|
||||
for el in elements:
|
||||
assert el.style["background-color"] != "red"
|
||||
|
||||
elements.style["background-color"] = "red"
|
||||
|
||||
for i, el in enumerate(web.page.find(selector)):
|
||||
assert elements[i].style["background-color"] == "red"
|
||||
assert el.style["background-color"] == "red"
|
||||
|
||||
elements.style.remove("background-color")
|
||||
|
||||
for i, el in enumerate(web.page.find(selector)):
|
||||
assert el.style["background-color"] != "red"
|
||||
assert elements[i].style["background-color"] != "red"
|
||||
|
||||
@upytest.skip(
|
||||
"Flakey in Pyodide on Worker",
|
||||
skip_when=RUNNING_IN_WORKER and not upytest.is_micropython,
|
||||
@@ -286,7 +558,7 @@ class TestCollection:
|
||||
called = False
|
||||
call_flag = asyncio.Event()
|
||||
|
||||
buttons_collection = web.page["button"]
|
||||
buttons_collection = web.page.find("button")
|
||||
|
||||
@when("click", buttons_collection)
|
||||
def on_click(event):
|
||||
@@ -304,27 +576,40 @@ class TestCollection:
|
||||
called = False
|
||||
call_flag.clear()
|
||||
|
||||
async def test_when_decorator_on_event(self):
|
||||
call_counter = 0
|
||||
call_flag = asyncio.Event()
|
||||
def test_update_all_single_attribute(self):
|
||||
"""Test updating single attribute on all elements."""
|
||||
div1 = web.div("Content 1")
|
||||
div2 = web.div("Content 2")
|
||||
collection = web.ElementCollection([div1, div2])
|
||||
|
||||
buttons_collection = web.page.find("button")
|
||||
number_of_clicks = len(buttons_collection)
|
||||
collection.update_all(className="updated")
|
||||
assert div1.className == "updated"
|
||||
assert div2.className == "updated"
|
||||
|
||||
@when(buttons_collection.on_click)
|
||||
def on_click(event):
|
||||
nonlocal call_counter
|
||||
call_counter += 1
|
||||
if call_counter == number_of_clicks:
|
||||
call_flag.set()
|
||||
def test_update_all_multiple_attributes(self):
|
||||
"""Test updating multiple attributes on all elements."""
|
||||
div1 = web.div()
|
||||
div2 = web.div()
|
||||
collection = web.ElementCollection([div1, div2])
|
||||
|
||||
# Now let's simulate a click on the button (using the low level JS API)
|
||||
# so we don't risk dom getting in the way
|
||||
assert call_counter == 0
|
||||
for button in buttons_collection:
|
||||
button._dom_element.click()
|
||||
await call_flag.wait()
|
||||
assert call_counter == number_of_clicks
|
||||
collection.update_all(innerHTML="Hello", title="Test")
|
||||
assert div1.innerHTML == "Hello"
|
||||
assert div1.title == "Test"
|
||||
assert div2.innerHTML == "Hello"
|
||||
assert div2.title == "Test"
|
||||
|
||||
def test_collection_getitem_by_id(self):
|
||||
"""Test looking up element by id in collection."""
|
||||
div1 = web.div(web.p("Child", id="find-me"))
|
||||
div2 = web.div(web.p("Child 2"))
|
||||
collection = web.ElementCollection([div1, div2])
|
||||
|
||||
web.page.body.append(div1)
|
||||
web.page.body.append(div2)
|
||||
|
||||
result = collection["find-me"]
|
||||
assert result is not None
|
||||
assert result.id == "find-me"
|
||||
|
||||
|
||||
class TestCreation:
|
||||
@@ -376,8 +661,7 @@ class TestInput:
|
||||
def test_value(self):
|
||||
for id_ in self.input_ids:
|
||||
expected_type = id_.split("_")[-1]
|
||||
result = web.page.find(f"#{id_}")
|
||||
input_el = result[0]
|
||||
input_el = web.page[f"#{id_}"]
|
||||
assert input_el._dom_element.type == expected_type
|
||||
assert (
|
||||
input_el.value == f"Content {id_}" == input_el._dom_element.value
|
||||
@@ -388,41 +672,6 @@ class TestInput:
|
||||
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 = web.page.find(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
|
||||
), f"Expected '{input_el.value}' to be 'Content {id_}' to be '{input_el._dom_element.value}'"
|
||||
|
||||
new_value = f"Content {id_}"
|
||||
input_el.value = new_value
|
||||
|
||||
# TODO: We only attach attributes to the classes that have them now which means we
|
||||
# would have to have some other way to help users if using attributes that aren't
|
||||
# actually on the class. Maybe a job for __setattr__?
|
||||
#
|
||||
# def test_element_without_value(self):
|
||||
# result = web.page.find(f"#tests-terminal"][0]
|
||||
# with upytest.raises(AttributeError):
|
||||
# result.value = "some value"
|
||||
#
|
||||
# def test_element_without_value_via_collection(self):
|
||||
# result = web.page.find(f"#tests-terminal"]
|
||||
# with upytest.raises(AttributeError):
|
||||
# result.value = "some value"
|
||||
|
||||
|
||||
class TestSelect:
|
||||
|
||||
@@ -532,7 +781,7 @@ class TestSelect:
|
||||
|
||||
def test_select_options_remove(self):
|
||||
# GIVEN the existing select element with 3 options
|
||||
select = web.page.find("#test_select_element_to_remove")[0]
|
||||
select = web.page["#test_select_element_to_remove"]
|
||||
|
||||
# EXPECT the select element to have 3 options
|
||||
assert len(select.options) == 4
|
||||
@@ -610,7 +859,7 @@ class TestElements:
|
||||
kwargs = {k: parse_value(v) for k, v in properties.items()}
|
||||
|
||||
# Let's make sure the target div to contain the element is empty.
|
||||
container = web.page["#test-element-container"][0]
|
||||
container = web.page["#test-element-container"]
|
||||
container.innerHTML = ""
|
||||
assert container.innerHTML == "", container.innerHTML
|
||||
|
||||
@@ -881,7 +1130,9 @@ class TestElements:
|
||||
assert el._dom_element.tagName == "LABEL"
|
||||
assert el.for_ == label_for, "The label should have the correct for attribute."
|
||||
# Ensure the label element is rendered with the correct "for" attribute
|
||||
assert f'for="{label_for}"' in el.outerHTML, "The label should have the correct 'for' attribute in its HTML."
|
||||
assert (
|
||||
f'for="{label_for}"' in el.outerHTML
|
||||
), "The label should have the correct 'for' attribute in its HTML."
|
||||
|
||||
def test_legend(self):
|
||||
self._create_el_and_basic_asserts("legend", "some text")
|
||||
@@ -1195,3 +1446,58 @@ class TestElements:
|
||||
assert el.children[1].id == "child2"
|
||||
assert el.children[1].parentNode.textContent == parent_full_content
|
||||
assert el.children[1].textContent == p2_text_content
|
||||
|
||||
|
||||
class TestPageObject:
|
||||
"""Test the Page object."""
|
||||
|
||||
def test_page_getitem_with_id(self):
|
||||
"""Test looking up element by id using page[id]."""
|
||||
result = web.page["test_id_selector"]
|
||||
assert result is not None
|
||||
assert result.id == "test_id_selector"
|
||||
|
||||
def test_page_getitem_with_hash(self):
|
||||
"""Test looking up element by id with # prefix."""
|
||||
result = web.page["#test_id_selector"]
|
||||
assert result is not None
|
||||
assert result.id == "test_id_selector"
|
||||
|
||||
def test_page_getitem_nonexistent(self):
|
||||
"""Test looking up nonexistent id returns None."""
|
||||
result = web.page["nonexistent-id"]
|
||||
assert result is None
|
||||
|
||||
def test_page_title_get(self):
|
||||
"""Test getting page title."""
|
||||
original_title = web.page.title
|
||||
assert isinstance(original_title, str)
|
||||
|
||||
def test_page_title_set(self):
|
||||
"""Test setting page title."""
|
||||
original = web.page.title
|
||||
web.page.title = "Test Title"
|
||||
assert web.page.title == "Test Title"
|
||||
web.page.title = original # Restore
|
||||
|
||||
|
||||
class TestErrorCases:
|
||||
"""Test error handling."""
|
||||
|
||||
def test_invalid_event_name(self):
|
||||
"""Test that invalid event names raise ValueError."""
|
||||
div = web.div()
|
||||
with upytest.raises(ValueError):
|
||||
div.on_nonexistent_event
|
||||
|
||||
def test_invalid_append_type(self):
|
||||
"""Test that appending invalid types raises TypeError."""
|
||||
div = web.div()
|
||||
with upytest.raises(TypeError):
|
||||
div.append(12345) # Numbers can't be appended
|
||||
|
||||
def test_event_name_without_on_prefix(self):
|
||||
"""Test that get_event requires on_ prefix."""
|
||||
div = web.div()
|
||||
with upytest.raises(ValueError):
|
||||
div.get_event("click") # Should be "on_click"
|
||||
|
||||
@@ -8,7 +8,13 @@ import upytest
|
||||
from pyscript import WebSocket
|
||||
|
||||
|
||||
@upytest.skip("Websocket tests are disabled.")
|
||||
# Websocket tests are disabled by default because they don't reliably work in
|
||||
# playwright based tests. Feel free to set this to False to enable them when
|
||||
# running tests locally in an actual browser (they all pass there).
|
||||
SKIP_WEBSOCKET_TESTS = True
|
||||
|
||||
|
||||
@upytest.skip("Websocket tests are disabled.", skip_when=SKIP_WEBSOCKET_TESTS)
|
||||
async def test_websocket_with_attributes():
|
||||
"""
|
||||
Event handlers assigned via object attributes.
|
||||
@@ -54,7 +60,7 @@ async def test_websocket_with_attributes():
|
||||
assert closed_flag is True
|
||||
|
||||
|
||||
@upytest.skip("Websocket tests are disabled.")
|
||||
@upytest.skip("Websocket tests are disabled.", skip_when=SKIP_WEBSOCKET_TESTS)
|
||||
async def test_websocket_with_init():
|
||||
"""
|
||||
Event handlers assigned via __init__ arguments.
|
||||
@@ -100,3 +106,174 @@ async def test_websocket_with_init():
|
||||
assert "request served by" in messages[0].lower()
|
||||
assert messages[1] == "Hello, world!"
|
||||
assert closed_flag is True
|
||||
|
||||
|
||||
@upytest.skip("Websocket tests are disabled.", skip_when=SKIP_WEBSOCKET_TESTS)
|
||||
async def test_websocket_async_handlers():
|
||||
"""
|
||||
Async event handlers should work correctly.
|
||||
"""
|
||||
messages = []
|
||||
ready_to_test = asyncio.Event()
|
||||
|
||||
async def on_open(event):
|
||||
await asyncio.sleep(0)
|
||||
ws.send("async test")
|
||||
|
||||
async def on_message(event):
|
||||
await asyncio.sleep(0)
|
||||
messages.append(event.data)
|
||||
if len(messages) == 2:
|
||||
ws.close()
|
||||
|
||||
async def on_close(event):
|
||||
await asyncio.sleep(0)
|
||||
ready_to_test.set()
|
||||
|
||||
ws = WebSocket(
|
||||
url="wss://echo.websocket.org",
|
||||
onopen=on_open,
|
||||
onmessage=on_message,
|
||||
onclose=on_close,
|
||||
)
|
||||
|
||||
await ready_to_test.wait()
|
||||
assert len(messages) == 2
|
||||
assert messages[1] == "async test"
|
||||
|
||||
|
||||
@upytest.skip("Websocket tests are disabled.", skip_when=SKIP_WEBSOCKET_TESTS)
|
||||
async def test_websocket_binary_data_conversion():
|
||||
"""
|
||||
WebSocket should convert binary data to memoryview for Python.
|
||||
"""
|
||||
messages = []
|
||||
ready_to_test = asyncio.Event()
|
||||
|
||||
def on_open(event):
|
||||
# Send binary data as bytearray.
|
||||
binary_data = bytearray([0x48, 0x65, 0x6C, 0x6C, 0x6F])
|
||||
ws.send(binary_data)
|
||||
|
||||
def on_message(event):
|
||||
messages.append(event.data)
|
||||
if len(messages) == 2:
|
||||
ws.close()
|
||||
|
||||
def on_close(event):
|
||||
ready_to_test.set()
|
||||
|
||||
ws = WebSocket(
|
||||
url="wss://echo.websocket.org",
|
||||
onopen=on_open,
|
||||
onmessage=on_message,
|
||||
onclose=on_close,
|
||||
)
|
||||
|
||||
await ready_to_test.wait()
|
||||
assert len(messages) == 2
|
||||
# Verify wrapper converts binary to memoryview Python type.
|
||||
assert isinstance(messages[1], memoryview)
|
||||
|
||||
|
||||
@upytest.skip("Websocket tests are disabled.", skip_when=SKIP_WEBSOCKET_TESTS)
|
||||
async def test_websocket_send_bytes_conversion():
|
||||
"""
|
||||
WebSocket send should convert Python bytes to JS Uint8Array.
|
||||
"""
|
||||
messages = []
|
||||
ready_to_test = asyncio.Event()
|
||||
|
||||
def on_open(event):
|
||||
# Test that bytes are converted properly.
|
||||
ws.send(bytes([0x41, 0x42, 0x43]))
|
||||
|
||||
def on_message(event):
|
||||
messages.append(event.data)
|
||||
if len(messages) == 2:
|
||||
ws.close()
|
||||
|
||||
def on_close(event):
|
||||
ready_to_test.set()
|
||||
|
||||
ws = WebSocket(
|
||||
url="wss://echo.websocket.org",
|
||||
onopen=on_open,
|
||||
onmessage=on_message,
|
||||
onclose=on_close,
|
||||
)
|
||||
|
||||
await ready_to_test.wait()
|
||||
assert len(messages) == 2
|
||||
|
||||
|
||||
@upytest.skip("Websocket tests are disabled.", skip_when=SKIP_WEBSOCKET_TESTS)
|
||||
async def test_websocket_event_wrapper():
|
||||
"""
|
||||
WebSocketEvent wrapper should provide access to event properties.
|
||||
"""
|
||||
event_types = []
|
||||
ready_to_test = asyncio.Event()
|
||||
|
||||
def on_open(event):
|
||||
event_types.append(event.type)
|
||||
ws.send("test")
|
||||
|
||||
def on_message(event):
|
||||
event_types.append(event.type)
|
||||
# Verify event wrapper exposes properties.
|
||||
assert hasattr(event, "data")
|
||||
assert hasattr(event, "type")
|
||||
if len(event_types) >= 2:
|
||||
ws.close()
|
||||
|
||||
def on_close(event):
|
||||
event_types.append(event.type)
|
||||
ready_to_test.set()
|
||||
|
||||
ws = WebSocket(
|
||||
url="wss://echo.websocket.org",
|
||||
onopen=on_open,
|
||||
onmessage=on_message,
|
||||
onclose=on_close,
|
||||
)
|
||||
|
||||
await ready_to_test.wait()
|
||||
assert "open" in event_types
|
||||
assert "message" in event_types
|
||||
assert "close" in event_types
|
||||
|
||||
|
||||
@upytest.skip("Websocket tests are disabled.", skip_when=SKIP_WEBSOCKET_TESTS)
|
||||
async def test_websocket_reassign_handler():
|
||||
"""
|
||||
Event handlers should be replaceable after creation.
|
||||
"""
|
||||
first_handler_called = False
|
||||
second_handler_called = False
|
||||
ready_to_test = asyncio.Event()
|
||||
|
||||
def first_handler(event):
|
||||
nonlocal first_handler_called
|
||||
first_handler_called = True
|
||||
|
||||
def second_handler(event):
|
||||
nonlocal second_handler_called
|
||||
second_handler_called = True
|
||||
ws.close()
|
||||
|
||||
def on_open(event):
|
||||
# Replace the message handler before any messages arrive.
|
||||
ws.onmessage = second_handler
|
||||
ws.send("test")
|
||||
|
||||
def on_close(event):
|
||||
ready_to_test.set()
|
||||
|
||||
ws = WebSocket(url="wss://echo.websocket.org", onopen=on_open, onclose=on_close)
|
||||
ws.onmessage = first_handler
|
||||
|
||||
await ready_to_test.wait()
|
||||
# Verify that handler replacement worked.
|
||||
assert first_handler_called is False
|
||||
assert second_handler_called is True
|
||||
|
||||
105
core/tests/python/tests/test_workers.py
Normal file
105
core/tests/python/tests/test_workers.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Tests for the pyscript.workers module.
|
||||
|
||||
Note: These tests can only run in the main thread since they test worker
|
||||
creation and access.
|
||||
|
||||
I've added the import of workers and create_named_worker inside each test
|
||||
so that the test module can still be imported in a worker context without
|
||||
errors. It also means the module is GC'd between tests, which is a good way
|
||||
to ensure each test is independent given the global nature of the workers
|
||||
proxy.
|
||||
"""
|
||||
|
||||
import upytest
|
||||
from pyscript import RUNNING_IN_WORKER
|
||||
|
||||
|
||||
@upytest.skip("Main thread only", skip_when=RUNNING_IN_WORKER)
|
||||
async def test_workers_proxy_exists():
|
||||
"""
|
||||
The workers proxy should be accessible and support both.
|
||||
bracket and dot notation.
|
||||
"""
|
||||
from pyscript import workers
|
||||
|
||||
assert workers is not None
|
||||
# Defined in the HTML.
|
||||
worker = await workers["testworker"]
|
||||
assert worker is not None
|
||||
worker = await workers.testworker
|
||||
assert worker is not None
|
||||
|
||||
|
||||
@upytest.skip("Main thread only", skip_when=RUNNING_IN_WORKER)
|
||||
async def test_worker_exported_functions():
|
||||
"""
|
||||
Functions exported from a worker should be callable.
|
||||
"""
|
||||
from pyscript import workers
|
||||
|
||||
worker = await workers["testworker"]
|
||||
# Test multiple exported functions.
|
||||
add_result = await worker.add(10, 20)
|
||||
multiply_result = await worker.multiply(4, 5)
|
||||
greeting = await worker.get_message()
|
||||
|
||||
assert add_result == 30
|
||||
assert multiply_result == 20
|
||||
assert greeting == "Hello from worker"
|
||||
|
||||
|
||||
@upytest.skip("Main thread only", skip_when=RUNNING_IN_WORKER)
|
||||
async def test_create_named_worker_basic():
|
||||
"""
|
||||
Creating a named worker dynamically should work.
|
||||
"""
|
||||
from pyscript import create_named_worker, workers
|
||||
|
||||
worker = await create_named_worker(
|
||||
src="./worker_functions.py", name="dynamic-test-worker"
|
||||
)
|
||||
|
||||
assert worker is not None
|
||||
# Verify we can call its functions.
|
||||
result = await worker.add(1, 2)
|
||||
assert result == 3
|
||||
# Verify it's also accessible via the workers proxy.
|
||||
same_worker = await workers["dynamic-test-worker"]
|
||||
result2 = await same_worker.add(3, 4)
|
||||
assert result2 == 7
|
||||
|
||||
|
||||
@upytest.skip("Main thread only", skip_when=RUNNING_IN_WORKER)
|
||||
async def test_create_named_worker_with_config():
|
||||
"""
|
||||
Creating a worker with configuration should work.
|
||||
"""
|
||||
from pyscript import create_named_worker
|
||||
|
||||
# Create worker with a PyScript configuration dict.
|
||||
worker = await create_named_worker(
|
||||
src="./worker_functions.py",
|
||||
name="configured-worker",
|
||||
config={"packages_cache": "never"},
|
||||
)
|
||||
assert worker is not None
|
||||
# Worker should still function normally.
|
||||
result = await worker.multiply(6, 7)
|
||||
assert result == 42
|
||||
|
||||
|
||||
@upytest.skip("Main thread only", skip_when=RUNNING_IN_WORKER)
|
||||
async def test_create_named_worker_micropython():
|
||||
"""
|
||||
Creating a MicroPython worker should work.
|
||||
"""
|
||||
from pyscript import create_named_worker
|
||||
|
||||
worker = await create_named_worker(
|
||||
src="./worker_functions.py", name="mpy-worker", type="mpy"
|
||||
)
|
||||
assert worker is not None
|
||||
# Verify functionality.
|
||||
result = await worker.add(100, 200)
|
||||
assert result == 300
|
||||
18
core/tests/python/worker_functions.py
Normal file
18
core/tests/python/worker_functions.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Numpty test code to run in a worker for pyscript.workers module tests.
|
||||
"""
|
||||
|
||||
|
||||
def add(a, b):
|
||||
return a + b
|
||||
|
||||
|
||||
def multiply(a, b):
|
||||
return a * b
|
||||
|
||||
|
||||
def get_message():
|
||||
return "Hello from worker"
|
||||
|
||||
|
||||
__export__ = ["add", "multiply", "get_message"]
|
||||
2
core/types/stdlib/pyscript.d.ts
vendored
2
core/types/stdlib/pyscript.d.ts
vendored
@@ -7,7 +7,7 @@ declare namespace _default {
|
||||
"ffi.py": string;
|
||||
"flatted.py": string;
|
||||
"fs.py": string;
|
||||
"magic_js.py": string;
|
||||
"context.py": string;
|
||||
"media.py": string;
|
||||
"storage.py": string;
|
||||
"util.py": string;
|
||||
|
||||
@@ -5,5 +5,5 @@ skip = "*.js,*.json"
|
||||
[tool.ruff]
|
||||
line-length = 114
|
||||
lint.select = ["C4", "C90", "E", "EM", "F", "PIE", "PYI", "PLC", "Q", "RET", "W"]
|
||||
lint.ignore = ["E402", "E722", "E731", "E741", "F401", "F704", "PLC0415"]
|
||||
lint.ignore = ["E402", "E722", "E731", "E741", "F401", "F704", "PLC0415", "EM101", "EM102", "RET505"]
|
||||
lint.mccabe.max-complexity = 27
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
black==24.10.0
|
||||
pre-commit==3.7.1
|
||||
python-minifier==2.11.0
|
||||
python-minifier==3.1.0
|
||||
setuptools==72.1.0
|
||||
|
||||
Reference in New Issue
Block a user