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:
Nicholas Tollervey
2025-12-11 17:19:24 +00:00
committed by GitHub
parent 90ae3cea95
commit a02ff691d2
38 changed files with 5808 additions and 1853 deletions

View File

@@ -11,7 +11,6 @@ repos:
hooks: hooks:
- id: check-builtin-literals - id: check-builtin-literals
- id: check-case-conflict - id: check-case-conflict
- id: check-docstring-first
- id: check-executables-have-shebangs - id: check-executables-have-shebangs
- id: check-json - id: check-json
exclude: tsconfig\.json exclude: tsconfig\.json

File diff suppressed because one or more lines are too long

View File

@@ -1,36 +1,105 @@
# Some notes about the naming conventions and the relationship between various """
# similar-but-different names. This is the main `pyscript` namespace. It provides the primary Pythonic API
# for users to interact with the
# import pyscript [browser's own API](https://developer.mozilla.org/en-US/docs/Web/API). It
# this package contains the main user-facing API offered by pyscript. All includes utilities for common activities such as displaying content, handling
# the names which are supposed be used by end users should be made events, fetching resources, managing local storage, and coordinating with
# available in pyscript/__init__.py (i.e., this file) web workers.
#
# import _pyscript The most important names provided by this namespace can be directly imported
# this is an internal module implemented in JS. It is used internally by from `pyscript`, for example:
# the pyscript package, end users should not use it directly. For its
# implementation, grep for `interpreter.registerJsModule("_pyscript", ```python
# ...)` in core.js from pyscript import display, HTML, fetch, when, storage, WebSocket
# ```
# import js
# this is the JS globalThis, as exported by pyodide and/or micropython's The following names are available in the `pyscript` namespace:
# FFIs. As such, it contains different things in the main thread or in a
# worker. - `RUNNING_IN_WORKER`: Boolean indicating if the code is running in a Web
# Worker.
# import pyscript.magic_js - `PyWorker`: Class for creating Web Workers running Python code.
# this submodule abstracts away some of the differences between the main - `config`: Configuration object for pyscript settings.
# thread and the worker. In particular, it defines `window` and `document` - `current_target`: The element in the DOM that is the current target for
# in such a way that these names work in both cases: in the main thread, output.
# they are the "real" objects, in the worker they are proxies which work - `document`: The standard `document` object, proxied in workers.
# thanks to coincident. - `window`: The standard `window` object, proxied in workers.
# - `js_import`: Function to dynamically import JS modules.
# from pyscript import window, document - `js_modules`: Object containing JS modules available to Python.
# these are just the window and document objects as defined by - `sync`: Utility for synchronizing between worker and main thread.
# pyscript.magic_js. This is the blessed way to access them from pyscript, - `display`: Function to render Python objects in the web page.
# as it works transparently in both the main thread and worker cases. - `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 polyscript import lazy_py_modules as py_import
from pyscript.magic_js import ( from pyscript.context import (
RUNNING_IN_WORKER, RUNNING_IN_WORKER,
PyWorker, PyWorker,
config, config,

View File

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

View File

@@ -1,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 base64
import html import html
import io import io
import re from collections import OrderedDict
from pyscript.context import current_target, document, window
from pyscript.magic_js import current_target, document, window
from pyscript.ffi import is_none 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): 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 image (`mime`) data (`value`) as an HTML img element with data URL.
# render it. 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): if isinstance(value, bytes):
value = base64.b64encode(value).decode("utf-8") value = base64.b64encode(value).decode("utf-8")
attrs = "".join([f' {k}="{v}"' for k, v in meta.items()])
# This is the pattern of base64 strings return f'<img src="data:{mime};base64,{value}"{attrs}>'
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>'
def _identity(value, meta): # Maps MIME types to rendering functions.
return value _MIME_TO_RENDERERS = {
"text/plain": lambda v, m: html.escape(v),
"text/html": lambda v, m: v,
_MIME_RENDERERS = { "image/png": lambda v, m: _render_image("image/png", v, m),
"text/plain": html.escape, "image/jpeg": lambda v, m: _render_image("image/jpeg", v, m),
"text/html": _identity, "image/svg+xml": lambda v, m: v,
"image/png": lambda value, meta: _render_image("image/png", value, meta), "application/json": lambda v, m: v,
"image/jpeg": lambda value, meta: _render_image("image/jpeg", value, meta), "application/javascript": lambda v, m: f"<script>{v}<\\/script>",
"image/svg+xml": _identity,
"application/json": _identity,
"application/javascript": lambda value, meta: f"<script>{value}<\\/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: 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): def __init__(self, html):
@@ -68,112 +108,156 @@ class HTML:
return self._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) return repr(obj)
if hasattr(obj, print_method): if not hasattr(obj, method):
if print_method == "savefig": return None
if method == "savefig":
buf = io.BytesIO() buf = io.BytesIO()
obj.savefig(buf, format="png") obj.savefig(buf, format="png")
buf.seek(0) buf.seek(0)
return base64.b64encode(buf.read()).decode("utf-8") return base64.b64encode(buf.read()).decode("utf-8")
return getattr(obj, print_method)() return getattr(obj, method)()
if print_method == "_repr_mimebundle_":
return {}, {}
return None
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): if isinstance(obj, str):
return html.escape(obj), "text/plain" return html.escape(obj), "text/plain"
# Prefer an object's mimebundle.
mimebundle = _eval_formatter(obj, "_repr_mimebundle_") mimebundle = _get_representation(obj, "_repr_mimebundle_")
if mimebundle:
if isinstance(mimebundle, tuple): if isinstance(mimebundle, tuple):
format_dict, _ = mimebundle # Grab global metadata.
format_dict, global_meta = mimebundle
else: else:
format_dict = mimebundle format_dict, global_meta = mimebundle, {}
# Try to render using mimebundle formats.
output, not_available = None, [] for mime_type, output in format_dict.items():
for method, mime_type in _MIME_METHODS.items(): if mime_type in _MIME_TO_RENDERERS:
if mime_type in format_dict: meta = global_meta.get(mime_type, {})
output = format_dict[mime_type] # If output is a tuple, merge format-specific metadata.
else: if isinstance(output, tuple):
output = _eval_formatter(obj, method) output, format_meta = output
meta.update(format_meta)
if is_none(output): 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 continue
if mime_type not in _MIME_RENDERERS: output = _get_representation(obj, method)
not_available.append(mime_type) if output is None:
continue 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 = {} 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): def _write_to_dom(element, value, append):
html, mime_type = _format_mime(value) """
if html == "\\n": 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 return
if append: if append:
out_element = document.createElement("div") container = document.createElement("div")
element.append(out_element) element.append(container)
else: else:
out_element = element.lastElementChild container = element
if is_none(out_element):
out_element = element
if mime_type in ("application/javascript", "text/html"): if mime_type in ("application/javascript", "text/html"):
script_element = document.createRange().createContextualFragment(html) container.append(document.createRange().createContextualFragment(html_content))
out_element.append(script_element)
else: else:
out_element.innerHTML = html container.innerHTML = html_content
def display(*values, target=None, append=True): 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() 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) element = document.getElementById(target)
# If target cannot be found on the page, a ValueError is raised
if is_none(element): if is_none(element):
msg = f"Invalid selector with id={target}. Cannot be found in the page." raise ValueError(f"Cannot find element with id='{target}' in the page.")
raise ValueError(msg) # If possible, use a script tag's target attribute.
# if element is a <script type="py">, it has a 'target' attribute which
# points to the visual element holding the displayed values. In that case,
# use that.
if element.tagName == "SCRIPT" and hasattr(element, "target"): if element.tagName == "SCRIPT" and hasattr(element, "target"):
element = element.target element = element.target
# Clear before displaying all values when not appending.
for v in values:
if not append: if not append:
element.replaceChildren() element.replaceChildren()
_write(element, v, append=append) # Add each value.
for value in values:
_write_to_dom(element, value, append)

View File

@@ -1,17 +1,47 @@
"""
Event handling for PyScript.
This module provides two complementary systems:
1. The `Event` class: A simple publish-subscribe pattern for custom events
within *your* Python code.
2. The `@when` decorator: Connects Python functions to browser DOM events,
or instances of the `Event` class, allowing you to respond to user
interactions like clicks, key presses and form submissions, or to custom
events defined in your Python code.
"""
import asyncio import asyncio
import inspect import inspect
import sys
from functools import wraps from functools import wraps
from pyscript.magic_js import document from pyscript.context import document
from pyscript.ffi import create_proxy from pyscript.ffi import create_proxy, to_js
from pyscript.util import is_awaitable from pyscript.util import is_awaitable
from pyscript import config
class Event: 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): def __init__(self):
@@ -19,116 +49,189 @@ class Event:
def trigger(self, result): 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: for listener in self._listeners:
if is_awaitable(listener): if is_awaitable(listener):
# Use create task to avoid making this an async function.
asyncio.create_task(listener(result)) asyncio.create_task(listener(result))
else: else:
listener(result) listener(result)
def add_listener(self, listener): 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: if listener not in self._listeners:
self._listeners.append(listener) 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 Remove specified `listeners`. If none specified, remove all listeners.
provided, clear all handlers.
""" """
if args: if listeners:
for listener in args: for listener in listeners:
try:
self._listeners.remove(listener) self._listeners.remove(listener)
except ValueError:
pass # Silently ignore listeners not in the list.
else: else:
self._listeners = [] 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. For DOM events, specify the `event_type` (e.g. `"click"`) and a `selector`
If the target is an Event object, the event listener will be added to that for target elements. For custom `Event` objects, just pass the `Event`
object. If the target is a string, the event listener will be added to the instance as the `event_type`. It's also possible to pass a list of `Event`
element(s) that match the (second) selector argument. 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 For DOM events only, you can specify optional
is triggered; thus allowing this to be used as both a function and a [addEventListener options](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#options):
decorator. `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 if isinstance(event_type, str):
# arguments. If there's no handler, this must be a decorator based call. # This is a DOM event to handle, so check and use the selector.
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 not selector: if not selector:
msg = "No selector provided." raise ValueError("Selector required for DOM event handling.")
raise ValueError(msg) elements = _get_elements(selector)
# Grab the DOM elements to which the target event will be attached. 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 from pyscript.web import Element, ElementCollection
if isinstance(selector, str): if isinstance(selector, str):
elements = document.querySelectorAll(selector) return list(document.querySelectorAll(selector))
elif isinstance(selector, Element): elif isinstance(selector, Element):
elements = [selector._dom_element] return [selector._dom_element]
elif isinstance(selector, ElementCollection): 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: else:
elements = selector if isinstance(selector, list) else [selector] return [selector]
def decorator(func):
sig = inspect.signature(func) def _create_wrapper(func):
if sig.parameters: """
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 is_awaitable(func):
if accepts_args:
async def wrapper(event): async def wrapper(event):
return await func(event) return await func(event)
else: else:
wrapper = func
else:
# Function doesn't receive events.
if is_awaitable(func):
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
return await func() 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: else:
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
return func() return func()
wrapper = wraps(func)(wrapper) return 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

View File

@@ -1,87 +1,218 @@
import json """
This module provides a Python-friendly interface to the
[browser's fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API),
returning native Python data types and supporting directly awaiting the promise
and chaining method calls directly on the promise.
```python
from pyscript.fetch import fetch
url = "https://api.example.com/data"
# Pattern 1: Await the response, then extract data.
response = await fetch(url)
if response.ok:
data = await response.json()
else:
raise NetworkError(f"Fetch failed: {response.status}")
# Pattern 2: Chain method calls directly on the promise.
data = await fetch(url).json()
```
"""
import json
import js import js
from pyscript.util import as_bytearray from pyscript.util import as_bytearray
### wrap the response to grant Pythonic results class _FetchResponse:
class _Response: """
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): def __init__(self, response):
self._response = response self._response = response
# grant access to response.ok and other fields
def __getattr__(self, attr): def __getattr__(self, attr):
"""
Provide access to underlying Response properties like ok, status, etc.
"""
return getattr(self._response, attr) return getattr(self._response, attr)
# exposed methods with Pythonic results
async def arrayBuffer(self): 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() buffer = await self._response.arrayBuffer()
# works in Pyodide
if hasattr(buffer, "to_py"): if hasattr(buffer, "to_py"):
# Pyodide conversion.
return buffer.to_py() return buffer.to_py()
# shims in MicroPython # MicroPython conversion.
return memoryview(as_bytearray(buffer)) return memoryview(as_bytearray(buffer))
async def blob(self): 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() return await self._response.blob()
async def bytearray(self): async def bytearray(self):
"""
Get response body as a Python bytearray.
Returns a mutable bytearray containing the response data.
"""
buffer = await self._response.arrayBuffer() buffer = await self._response.arrayBuffer()
return as_bytearray(buffer) return as_bytearray(buffer)
async def json(self): 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()) return json.loads(await self.text())
async def text(self): async def text(self):
"""
Get response body as a text string.
"""
return await self._response.text() return await self._response.text()
### allow direct await to _Response methods class _FetchPromise:
class _DirectResponse: """
@staticmethod Wraps the fetch promise to enable direct method chaining.
def setup(promise, response):
promise._response = _Response(response) This allows calling response methods directly on the fetch promise:
return promise._response `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): def __init__(self, promise):
self._promise = promise self._promise = promise
# To be resolved in the future via the setup() static method.
promise._response = None promise._response = None
# Add convenience methods directly to the promise.
promise.arrayBuffer = self.arrayBuffer promise.arrayBuffer = self.arrayBuffer
promise.blob = self.blob promise.blob = self.blob
promise.bytearray = self.bytearray promise.bytearray = self.bytearray
promise.json = self.json promise.json = self.json
promise.text = self.text 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: if not self._promise._response:
await self._promise await self._promise
return self._promise._response return self._promise._response
async def arrayBuffer(self): async def arrayBuffer(self):
response = await self._response() response = await self._get_response()
return await response.arrayBuffer() return await response.arrayBuffer()
async def blob(self): async def blob(self):
response = await self._response() response = await self._get_response()
return await response.blob() return await response.blob()
async def bytearray(self): async def bytearray(self):
response = await self._response() response = await self._get_response()
return await response.bytearray() return await response.bytearray()
async def json(self): async def json(self):
response = await self._response() response = await self._get_response()
return await response.json() return await response.json()
async def text(self): async def text(self):
response = await self._response() response = await self._get_response()
return await response.text() return await response.text()
def fetch(url, **kw): def fetch(url, **options):
# workaround Pyodide / MicroPython dict <-> js conversion """
options = js.JSON.parse(json.dumps(kw)) Fetch a resource from the network using a Pythonic interface.
awaited = lambda response, *args: _DirectResponse.setup(promise, response)
promise = js.fetch(url, options).then(awaited) This wraps JavaScript's fetch API, returning Python-native data types
_DirectResponse(promise) 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 return promise

View File

@@ -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: try:
# Attempt to import Pyodide's FFI utilities.
import js import js
from pyodide.ffi import create_proxy as _cp from pyodide.ffi import create_proxy as _cp
from pyodide.ffi import to_js as _py_tjs from pyodide.ffi import to_js as _py_tjs
from pyodide.ffi import jsnull from pyodide.ffi import jsnull
from_entries = js.Object.fromEntries from_entries = js.Object.fromEntries
is_none = lambda value: value is None or value is jsnull
def _tjs(value, **kw): def _to_js_wrapper(value, **kw):
if not hasattr(kw, "dict_converter"): if "dict_converter" not in kw:
kw["dict_converter"] = from_entries kw["dict_converter"] = from_entries
return _py_tjs(value, **kw) return _py_tjs(value, **kw)
except: except:
# Fallback to jsffi for MicroPython.
from jsffi import create_proxy as _cp 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 import js
jsnull = js.Object.getPrototypeOf(js.Object.prototype) 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: try:
# Worker context utilities from reflected-ffi.
# See https://github.com/WebReflection/reflected-ffi for more details.
from polyscript import ffi as _ffi from polyscript import ffi as _ffi
_assign = _ffi.assign
direct = _ffi.direct direct = _ffi.direct
gather = _ffi.gather gather = _ffi.gather
query = _ffi.query query = _ffi.query
def assign(source, *args):
for arg in args:
_ffi.assign(source, to_js(arg))
return source
except: except:
# Fallback implementations for main thread context.
import js import js
_assign = js.Object.assign _assign = js.Object.assign
direct = lambda source: source 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: for arg in args:
_assign(source, to_js(arg)) _assign(source, to_js(arg))
return source return source

View File

@@ -1,4 +1,36 @@
# https://www.npmjs.com/package/flatted """
This module is a Python implementation of the
[Flatted JavaScript library](https://www.npmjs.com/package/flatted), which
provides a light and fast way to serialize and deserialize JSON structures
that contain circular references.
Standard JSON cannot handle circular references - attempting to serialize an
object that references itself will cause an error. Flatted solves this by
transforming circular structures into a flat array format that can be safely
serialized and later reconstructed.
Common use cases:
- Serializing complex object graphs with circular references.
- Working with DOM-like structures that contain parent/child references.
- Preserving object identity when serializing data structures.
```python
from pyscript import flatted
# Create a circular structure.
obj = {"name": "parent"}
obj["self"] = obj # Circular reference!
# Standard json.dumps would fail here.
serialized = flatted.stringify(obj)
# Reconstruct the original structure.
restored = flatted.parse(serialized)
assert restored["self"] is restored # Circular reference preserved!
```
"""
import json as _json import json as _json
@@ -114,6 +146,26 @@ def _wrap(value):
def parse(value, *args, **kwargs): 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) json = _json.loads(value, *args, **kwargs)
wrapped = [] wrapped = []
for value in json: for value in json:
@@ -138,6 +190,31 @@ def parse(value, *args, **kwargs):
def stringify(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() known = _Known()
input = [] input = []
output = [] output = []

View File

@@ -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 = {} 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 handler = details.handler
options = details.options options = details.options
permission = await handler.queryPermission(options) permission = await handler.queryPermission(options)
@@ -9,94 +65,194 @@ async def get_handler(details):
async def mount(path, mode="readwrite", root="", id="pyscript"): async def mount(path, mode="readwrite", root="", id="pyscript"):
import js """
from _pyscript import fs, interpreter Mount a directory from the local filesystem to the virtual filesystem
from pyscript.ffi import to_js at the specified `path` mount point. The `mode` can be "readwrite" or
from pyscript.magic_js import ( "read" to specify access level. The `root` parameter provides a hint
RUNNING_IN_WORKER, for the file picker starting location. The `id` parameter allows multiple
sync, 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 ⚠️") 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 details = None
handler = None handler = None
uid = f"{path}@{id}"
options = {"id": id, "mode": mode} options = {"id": id, "mode": mode}
if root != "": if root != "":
options["startIn"] = root options["startIn"] = root
if RUNNING_IN_WORKER: 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 # Handle both async and SharedArrayBuffer use cases.
if isinstance(fsh, bool): if isinstance(fs_handler, bool):
success = fsh success = fs_handler
else: else:
success = await fsh success = await fs_handler
if success: if success:
from polyscript import IDBMap idbm = IDBMap.new(_fs.NAMESPACE)
from pyscript import window details = await idbm.get(mount_key)
handler = await _check_permission(details)
idbm = IDBMap.new(fs.NAMESPACE)
details = await idbm.get(uid)
handler = await get_handler(details)
if handler is None: if handler is None:
# force await in either async or sync scenario # Force await in either async or sync scenario.
await js.Promise.resolve(sync.getFSHandler(details.options)) await js.Promise.resolve(sync_with_worker.getFSHandler(details.options))
handler = details.handler handler = details.handler
else:
raise RuntimeError(_fs.ERROR)
else: else:
raise RuntimeError(fs.ERROR) success = await _fs.idb.has(mount_key)
else:
success = await fs.idb.has(uid)
if success: if success:
details = await fs.idb.get(uid) details = await _fs.idb.get(mount_key)
handler = await get_handler(details) handler = await _check_permission(details)
if handler is None: if handler is None:
handler = await fs.getFileSystemDirectoryHandle(details.options) handler = await _fs.getFileSystemDirectoryHandle(details.options)
else: else:
js_options = to_js(options) js_options = to_js(options)
handler = await fs.getFileSystemDirectoryHandle(js_options) handler = await _fs.getFileSystemDirectoryHandle(js_options)
details = {"handler": handler, "options": 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) 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): 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() await mounted[path].syncfs()
async def unmount(path): 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) await sync(path)
interpreter._module.FS.unmount(path) interpreter._module.FS.unmount(path)
del mounted[path]
async def revoke(path, id="pyscript"):
"""
Revoke filesystem access permission and unmount for a given
`path` and `id` combination.
This removes the stored permission for accessing the user's local
filesystem at the specified path and ID. Unlike `unmount()`, which only
removes the mount point, `revoke()` also clears the permission so the
user will be prompted again on next mount.
```python
from pyscript import fs
await fs.mount("/local", id="my-app")
# ... work with files ...
# Revoke permission (user will be prompted again next time).
revoked = await fs.revoke("/local", id="my-app")
if revoked:
print("Permission revoked successfully")
```
After revoking, the user will need to grant permission again and
select a directory when `mount()` is called next time.
"""
mount_key = f"{path}@{id}"
if RUNNING_IN_WORKER:
handler_exists = sync_with_worker.deleteFSHandler(mount_key)
else:
handler_exists = await _fs.idb.has(mount_key)
if handler_exists:
handler_exists = await _fs.idb.delete(mount_key)
if handler_exists:
interpreter._module.FS.unmount(path)
if path in mounted:
del mounted[path]
return handler_exists

View File

@@ -1,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

View File

@@ -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 import window
from pyscript.ffi import to_js from pyscript.ffi import to_js
class Device: 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): def __init__(self, device):
self._dom_element = device """
Create a Device wrapper around a MediaDeviceInfo `device`.
"""
self._device_info = device
@property @property
def id(self): 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 @property
def group(self): 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 @property
def kind(self): def kind(self):
return self._dom_element.kind """
Device type: `"videoinput"`, `"audioinput"`, or `"audiooutput"`.
"""
return self._device_info.kind
@property @property
def label(self): 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): 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) 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 @classmethod
async def load(cls, audio=False, video=True): 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 = {} return await cls.request_stream(audio=audio, video=video)
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))
async def get_stream(self): 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, Get a media stream from this specific device.
such as microphones, cameras, headsets, and so forth.
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 # List all devices.
group identifier if they belong to the same physical device — for devices = await list_devices()
example a monitor with both a built-in camera and a microphone.
* kind: an enumerated value that is either "videoinput", "audioinput" # Find a specific camera.
or "audiooutput". 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 # Get a stream from that specific camera.
Webcam"). if my_camera:
stream = await my_camera.get_stream()
```
Note: the returned list will omit any devices that are blocked by the document This will trigger a permission dialog if the user hasn't already
Permission Policy: microphone, camera, speaker-selection (for output devices), granted permission for this device type.
and so on. Access to particular non-default devices is also gated by the
Permissions API, and the list will omit devices for which the user has not
granted explicit permission.
""" """
# https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices # Extract media type from device kind (e.g., "videoinput" -> "video").
return [ media_type = self.kind.replace("input", "").replace("output", "")
Device(obj) for obj in await window.navigator.mediaDevices.enumerateDevices() # Request stream with exact device ID constraint.
] options = {media_type: {"deviceId": {"exact": self.id}}}
return await self.request_stream(**options)
async def list_devices():
"""
Returns a list of all media devices currently available to the browser,
such as microphones, cameras, and speakers.
```python
from pyscript.media import list_devices
# Get all devices.
devices = await list_devices()
# Print device information.
for device in devices:
print(f"{device.kind}: {device.label} (ID: {device.id})")
# Filter for specific device types.
cameras = [d for d in devices if d.kind == "videoinput"]
microphones = [d for d in devices if d.kind == "audioinput"]
speakers = [d for d in devices if d.kind == "audiooutput"]
```
The returned list will omit devices that are blocked by the document
[Permission Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Permissions_Policy)
(microphone, camera, speaker-selection) or for
which the user has not granted explicit permission.
For security and privacy, device labels may be empty strings until
permission is granted. See
[this document](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices)
for more information about this web standard.
"""
device_infos = await window.navigator.mediaDevices.enumerateDevices()
return [Device(device_info) for device_info in device_infos]

View File

@@ -1,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 parse as _parse
from pyscript.flatted import stringify as _stringify from pyscript.flatted import stringify as _stringify
from pyscript.ffi import is_none from pyscript.ffi import is_none
# convert a Python value into an IndexedDB compatible entry def _convert_to_idb(value):
def _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): if is_none(value):
return _stringify(["null", 0]) return _stringify(["null", 0])
if isinstance(value, (bool, float, int, str, list, dict, tuple)): if isinstance(value, (bool, float, int, str, list, dict, tuple)):
@@ -14,50 +72,179 @@ def _to_idb(value):
return _stringify(["bytearray", list(value)]) return _stringify(["bytearray", list(value)])
if isinstance(value, memoryview): if isinstance(value, memoryview):
return _stringify(["memoryview", list(value)]) return _stringify(["memoryview", list(value)])
msg = f"Unexpected value: {value}" raise TypeError(f"Cannot serialize type {type(value).__name__} for storage.")
raise TypeError(msg)
# convert an IndexedDB compatible entry into a Python value def _convert_from_idb(value):
def _from_idb(value): """
( Convert an IndexedDB `value` back to its Python representation.
kind,
result, Uses type information stored during serialization to reconstruct the
) = _parse(value) original Python type.
"""
kind, data = _parse(value)
if kind == "null": if kind == "null":
return None return None
if kind == "generic": if kind == "generic":
return result return data
if kind == "bytearray": if kind == "bytearray":
return bytearray(result) return bytearray(data)
if kind == "memoryview": if kind == "memoryview":
return memoryview(bytearray(result)) return memoryview(bytearray(data))
# Fallback for all other types.
return value return value
class Storage(dict): 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): 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): def __delitem__(self, key):
self.__store__.delete(attr) """
super().__delitem__(attr) Delete an item from storage via its `key`.
def __setitem__(self, attr, value): The deletion is queued for persistence. Use `sync()` to ensure
self.__store__.set(attr, _to_idb(value)) immediate completion.
super().__setitem__(attr, value) """
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): 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() super().clear()
async def sync(self): 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): 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: if not name:
msg = "The storage name must be defined" raise ValueError("Storage name must be a non-empty string")
raise ValueError(msg)
return storage_class(await _storage(f"@pyscript/{name}")) underlying_store = await _polyscript_storage(f"@pyscript/{name}")
return storage_class(underlying_store)

View File

@@ -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 js
import sys
import inspect import inspect
def as_bytearray(buffer): 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. MicroPython friendly manner.
""" """
ui8a = js.Uint8Array.new(buffer) ui8a = js.Uint8Array.new(buffer)
@@ -42,17 +55,25 @@ class NotSupported:
def is_awaitable(obj): def is_awaitable(obj):
""" """
Returns a boolean indication if the passed in obj is an awaitable Returns a boolean indication if the passed in obj is an awaitable
function. (MicroPython treats awaitables as generator functions, and if function. This is interpreter agnostic.
the object is a closure containing an async function we need to work
carefully.) !!! 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 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 # 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. # 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 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.isgeneratorfunction(obj)
return inspect.iscoroutinefunction(obj) return inspect.iscoroutinefunction(obj)

File diff suppressed because it is too large Load Diff

View File

@@ -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 import js
from pyscript.ffi import create_proxy from pyscript.ffi import create_proxy
from pyscript.util import as_bytearray, is_awaitable 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): Creates a JavaScript proxy for the handler and wraps async handlers
p = create_proxy(listener) appropriately. Handles the `WebSocketEvent` wrapping for all handlers.
"""
if is_awaitable(handler_function):
if is_awaitable(listener): async def async_wrapper(event):
await handler_function(WebSocketEvent(event))
async def wrapper(e):
await p(EventMessage(e))
m = wrapper
wrapped_handler = create_proxy(async_wrapper)
else: else:
m = lambda e: p(EventMessage(e)) wrapped_handler = create_proxy(
lambda event: handler_function(WebSocketEvent(event))
# Pyodide fails at setting socket[onevent] directly )
setattr(socket, onevent, m) # 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): def __init__(self, event):
"""
Create a WebSocketEvent wrapper from an underlying JavaScript
`event`.
"""
self._event = event self._event = event
def __getattr__(self, attr): 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 attr == "data" and not isinstance(value, str):
if hasattr(value, "to_py"): if hasattr(value, "to_py"):
# Pyodide - convert JavaScript typed array to Python.
return value.to_py() return value.to_py()
# shims in MicroPython else:
# MicroPython - manually convert JS ArrayBuffer.
return memoryview(as_bytearray(value)) return memoryview(as_bytearray(value))
return value return value
class WebSocket: 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 CONNECTING = 0
OPEN = 1 OPEN = 1
CLOSING = 2 CLOSING = 2
CLOSED = 3 CLOSED = 3
def __init__(self, **kw): def __init__(self, url, protocols=None, **handlers):
url = kw["url"] """
if protocols in kw: Create a new WebSocket connection from the given `url` (`ws://` or
socket = js.WebSocket.new(url, kw[protocols]) `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: else:
socket = js.WebSocket.new(url) js_websocket = js.WebSocket.new(url)
# Set binary type to arraybuffer for easier Python handling.
socket.binaryType = "arraybuffer" js_websocket.binaryType = "arraybuffer"
object.__setattr__(self, "_ws", socket) # Store the underlying WebSocket.
# Use object.__setattr__ to bypass our custom __setattr__.
for t in methods: object.__setattr__(self, "_js_websocket", js_websocket)
if t in kw: # Attach any event handlers passed as keyword arguments.
add_listener(socket, t, kw[t]) for handler_name, handler in handlers.items():
setattr(self, handler_name, handler)
def __getattr__(self, attr): 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): def __setattr__(self, attr, value):
if attr in methods: """
add_listener(self._ws, attr, value) Set an attribute `attr` on the WebSocket to the given `value`.
else:
setattr(self._ws, attr, value)
def close(self, **kw): Event handler attributes (`onopen`, `onmessage`, etc.) are specially
if code in kw and reason in kw: handled to create proper proxies. Other attributes are set on the
self._ws.close(kw[code], kw[reason]) underlying WebSocket directly.
elif code in kw: """
self._ws.close(kw[code]) if attr in ["onclose", "onerror", "onmessage", "onopen"]:
_attach_event_handler(self._js_websocket, attr, value)
else: else:
self._ws.close() setattr(self._js_websocket, attr, value)
def send(self, data): 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): if isinstance(data, str):
self._ws.send(data) self._js_websocket.send(data)
else: else:
buffer = js.Uint8Array.new(len(data)) buffer = js.Uint8Array.new(len(data))
for pos, b in enumerate(data): for index, byte_value in enumerate(data):
buffer[pos] = b buffer[index] = byte_value
self._ws.send(buffer) self._js_websocket.send(buffer)
def close(self, code=None, reason=None):
"""
Close the WebSocket connection. Optionally specify a `code` (`int`)
and a `reason` (`str`) for closing the connection.
```python
# Normal close.
ws.close()
# Close with code and reason.
ws.close(code=1000, reason="Task completed")
```
Usage and values for `code` and `reasons`
[are explained here](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close).
"""
if code and reason:
self._js_websocket.close(code, reason)
elif code:
self._js_websocket.close(code)
else:
self._js_websocket.close()

View File

@@ -1,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=""): calc = await workers["calculator"]
script.setAttribute(name, value) 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 # Create a worker from a Python file.
# @see https://github.com/pyscript/pyscript/issues/2106 worker = await create_named_worker(
class _ReadOnlyProxy: 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): 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): 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"): async def create_named_worker(src, name, config=None, type="py"):
from json import dumps """
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: This function creates a new web worker by injecting a `<script>` tag into
msg = "Named workers require src" the document. The worker will be accessible via the `workers` proxy once
raise ValueError(msg) it's ready.
if not name: It returns a promise that resolves to the worker reference when ready.
msg = "Named workers require a name"
raise ValueError(msg)
s = _js.document.createElement("script") ```python
s.type = type from pyscript import create_named_worker
s.src = src
_set(s, "worker")
_set(s, "name", name)
# 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: if config:
_set(s, "config", (isinstance(config, str) and config) or dumps(config)) if isinstance(config, str):
config_str = config
_js.document.body.append(s) 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] return await workers[name]

View File

@@ -1,6 +1,6 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
const timeout = 60 * 1000; const timeout = 120 * 1000;
test.setTimeout(timeout); test.setTimeout(timeout);

View File

@@ -23,6 +23,8 @@
<div id="div-no-classes"></div> <div id="div-no-classes"></div>
<script type="py" worker name="testworker" src="./worker_functions.py"></script>
<div style="visibility: hidden;"> <div style="visibility: hidden;">
<h2>Test Read and Write</h2> <h2>Test Read and Write</h2>
<div id="test_rr_div">Content test_rr_div</div> <div id="test_rr_div">Content test_rr_div</div>
@@ -63,6 +65,7 @@
<button>I'm another button you can click</button> <button>I'm another button you can click</button>
<button id="a-third-button">2 is better than 3 :)</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="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> <div id="element-append-tests"></div>
<p class="collection"></p> <p class="collection"></p>

View File

@@ -1,27 +1,26 @@
{ {
"files": { "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_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_current_target.py": "tests/test_current_target.py",
"./tests/test_display.py": "tests/test_display.py", "./tests/test_display.py": "tests/test_display.py",
"./tests/test_document.py": "tests/test_document.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_fetch.py": "tests/test_fetch.py",
"./tests/test_ffi.py": "tests/test_ffi.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_media.py": "tests/test_media.py",
"./tests/test_storage.py": "tests/test_storage.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_running_in_worker.py": "tests/test_running_in_worker.py",
"./tests/test_web.py": "tests/test_web.py", "./tests/test_web.py": "tests/test_web.py",
"./tests/test_websocket.py": "tests/test_websocket.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": { "js_modules": {
"main": { "main": {"./example_js_module.js": "greeting"},
"./example_js_module.js": "greeting" "worker": {"./example_js_worker_module.js": "greeting_worker"}
},
"worker": {
"./example_js_worker_module.js": "greeting_worker"
}
} }
} }

View File

@@ -1,29 +1,28 @@
{ {
"files": { "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_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_current_target.py": "tests/test_current_target.py",
"./tests/test_display.py": "tests/test_display.py", "./tests/test_display.py": "tests/test_display.py",
"./tests/test_document.py": "tests/test_document.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_fetch.py": "tests/test_fetch.py",
"./tests/test_ffi.py": "tests/test_ffi.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_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_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_running_in_worker.py": "tests/test_running_in_worker.py",
"./tests/test_web.py": "tests/test_web.py", "./tests/test_web.py": "tests/test_web.py",
"./tests/test_websocket.py": "tests/test_websocket.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": { "js_modules": {
"main": { "main": {"./example_js_module.js": "greeting"},
"./example_js_module.js": "greeting" "worker": {"./example_js_worker_module.js": "greeting_worker"}
}, },
"worker": { "packages": ["Pillow"],
"./example_js_worker_module.js": "greeting_worker"
}
},
"packages": ["Pillow" ],
"experimental_ffi_timeout": 0 "experimental_ffi_timeout": 0
} }

View File

@@ -13,7 +13,7 @@ def test_current_target():
""" """
expected = "py-0" expected = "py-0"
if is_micropython: 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: elif RUNNING_IN_WORKER:
expected = "py-w0-target" expected = "py-w1-target"
assert current_target() == expected, f"Expected {expected} got {current_target()}" assert current_target() == expected, f"Expected {expected} got {current_target()}"

View File

@@ -3,6 +3,7 @@ Tests for the display function in PyScript.
""" """
import asyncio import asyncio
import json
import upytest import upytest
from pyscript import HTML, RUNNING_IN_WORKER, display, py_import, web 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: with upytest.raises(ValueError) as exc:
display("hello world", target="") display("hello world", target="")
assert str(exc.exception) == "Cannot have an empty target" assert str(exc.exception) == "Cannot find element with id='' in the page."
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"
async def test_tag_target_attribute(): async def test_tag_target_attribute():
@@ -286,4 +274,406 @@ async def test_image_renders_correctly():
display(img, target="test-element-container", append=False) display(img, target="test-element-container", append=False)
target = web.page.find("#test-element-container")[0] target = web.page.find("#test-element-container")[0]
img = target.find("img")[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 "&lt;script&gt;" 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

View File

@@ -24,15 +24,26 @@ def teardown():
def test_event_add_listener(): def test_event_add_listener():
""" """
Adding a listener to an event should add it to the list of listeners. It Adding a listener to an event should add it to the list of listeners.
should only be added once. It should only be added once.
""" """
event = Event() event = Event()
listener = lambda x: x listener = lambda x: x
event.add_listener(listener) event.add_listener(listener)
event.add_listener(listener) event.add_listener(listener)
assert len(event._listeners) == 1 # Only one item added. assert len(event._listeners) == 1
assert listener in event._listeners # The item is the expected listener. 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(): def test_event_remove_listener():
@@ -45,12 +56,25 @@ def test_event_remove_listener():
listener2 = lambda x: x listener2 = lambda x: x
event.add_listener(listener1) event.add_listener(listener1)
event.add_listener(listener2) event.add_listener(listener2)
assert len(event._listeners) == 2 # Two listeners added. assert len(event._listeners) == 2
assert listener1 in event._listeners # The first listener is in the list. assert listener1 in event._listeners
assert listener2 in event._listeners # The second listener is in the list. assert listener2 in event._listeners
event.remove_listener(listener1) event.remove_listener(listener1)
assert len(event._listeners) == 1 # Only one item remains. assert len(event._listeners) == 1
assert listener2 in event._listeners # The second listener is in the list. 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(): def test_event_remove_all_listeners():
@@ -62,15 +86,15 @@ def test_event_remove_all_listeners():
listener2 = lambda x: x listener2 = lambda x: x
event.add_listener(listener1) event.add_listener(listener1)
event.add_listener(listener2) event.add_listener(listener2)
assert len(event._listeners) == 2 # Two listeners added. assert len(event._listeners) == 2
event.remove_listener() event.remove_listener()
assert len(event._listeners) == 0 # No listeners remain. assert len(event._listeners) == 0
def test_event_trigger(): def test_event_trigger():
""" """
Triggering an event should call all of the listeners with the provided Triggering an event should call all of the listeners with the provided
arguments. result.
""" """
event = Event() event = Event()
counter = 0 counter = 0
@@ -81,15 +105,45 @@ def test_event_trigger():
assert x == "ok" assert x == "ok"
event.add_listener(listener) event.add_listener(listener)
assert counter == 0 # The listener has not been triggered yet. assert counter == 0
event.trigger("ok") 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(): async def test_event_trigger_with_awaitable():
""" """
Triggering an event with an awaitable listener should call the listener Triggering an event with an awaitable listener should call the listener
with the provided arguments. with the provided result.
""" """
call_flag = asyncio.Event() call_flag = asyncio.Event()
event = Event() event = Event()
@@ -102,16 +156,119 @@ async def test_event_trigger_with_awaitable():
call_flag.set() call_flag.set()
event.add_listener(listener) event.add_listener(listener)
assert counter == 0 # The listener has not been triggered yet. assert counter == 0
event.trigger("ok") event.trigger("ok")
await call_flag.wait() 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(): async def test_when_decorator_with_event():
""" """
When the decorated function takes a single parameter, When the decorated function takes a single parameter, it should be
it should be passed the event object. passed the event object.
""" """
btn = web.button("foo_button", id="foo_id") btn = web.button("foo_button", id="foo_id")
container = get_container() container = get_container()
@@ -133,8 +290,8 @@ async def test_when_decorator_with_event():
async def test_when_decorator_without_event(): async def test_when_decorator_without_event():
""" """
When the decorated function takes no parameters (not including 'self'), When the decorated function takes no parameters, it should be called
it should be called without the event object. without the event object.
""" """
btn = web.button("foo_button", id="foo_id") btn = web.button("foo_button", id="foo_id")
container = get_container() container = get_container()
@@ -143,7 +300,7 @@ async def test_when_decorator_without_event():
called = False called = False
call_flag = asyncio.Event() call_flag = asyncio.Event()
@web.when("click", selector="#foo_id") @when("click", selector="#foo_id")
def foo(): def foo():
nonlocal called nonlocal called
called = True called = True
@@ -156,8 +313,8 @@ async def test_when_decorator_without_event():
async def test_when_decorator_with_event_as_async_handler(): async def test_when_decorator_with_event_as_async_handler():
""" """
When the decorated function takes a single parameter, When the decorated function takes a single parameter, it should be
it should be passed the event object. Async version. passed the event object. Async version.
""" """
btn = web.button("foo_button", id="foo_id") btn = web.button("foo_button", id="foo_id")
container = get_container() 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(): async def test_when_decorator_without_event_as_async_handler():
""" """
When the decorated function takes no parameters (not including 'self'), When the decorated function takes no parameters, it should be called
it should be called without the event object. Async version. without the event object. Async version.
""" """
btn = web.button("foo_button", id="foo_id") btn = web.button("foo_button", id="foo_id")
container = get_container() container = get_container()
@@ -189,7 +346,7 @@ async def test_when_decorator_without_event_as_async_handler():
called = False called = False
call_flag = asyncio.Event() call_flag = asyncio.Event()
@web.when("click", selector="#foo_id") @when("click", selector="#foo_id")
async def foo(): async def foo():
nonlocal called nonlocal called
called = True called = True
@@ -202,7 +359,7 @@ async def test_when_decorator_without_event_as_async_handler():
async def test_two_when_decorators(): 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") btn = web.button("foo_button", id="foo_id")
container = get_container() container = get_container()
@@ -235,7 +392,7 @@ async def test_two_when_decorators():
async def test_when_decorator_multiple_elements(): async def test_when_decorator_multiple_elements():
""" """
The @when decorator's selector should successfully select multiple The @when decorator's selector should successfully select multiple
DOM elements DOM elements.
""" """
btn1 = web.button( btn1 = web.button(
"foo_button1", "foo_button1",
@@ -283,7 +440,8 @@ async def test_when_decorator_multiple_elements():
) )
def test_when_decorator_invalid_selector(): 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: if upytest.is_micropython:
from jsffi import JsException 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) 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(): def test_when_decorates_an_event():
""" """
When the @when decorator is used on a function to handle an Event instance, When the @when decorator is used on a function to handle an Event
the function should be called when the Event object is triggered. instance, the function should be called when the Event object is
triggered.
""" """
whenable = Event() whenable = Event()
counter = 0 counter = 0
# When as a decorator.
@when(whenable) @when(whenable)
def handler(result): def handler(result):
""" """
A function that should be called when the whenable object is triggered. 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 nonlocal counter
counter += 1 counter += 1
assert result == "ok" assert result == "ok"
# The function should not be called until the whenable object is triggered.
assert counter == 0 assert counter == 0
# Trigger the whenable object.
whenable.trigger("ok") whenable.trigger("ok")
# The function should have been called when the whenable object was
# triggered.
assert counter == 1 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, The @when decorator should handle a list of Event objects.
and a handler function.
""" """
whenable = Event() event1 = Event()
event2 = Event()
counter = 0 counter = 0
@when([event1, event2])
def handler(result): 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 nonlocal counter
counter += 1 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 assert counter == 0
# Trigger the whenable object. event1.trigger("test1")
whenable.trigger("ok") assert counter == 1
# The function should have been called when the whenable object was event2.trigger("test2")
# triggered. 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 assert counter == 1
def test_when_on_different_callables():
async def test_when_with_element_selector():
""" """
The when function works with: The @when decorator should accept an Element object as selector.
* Synchronous functions
* Asynchronous functions
* Inner functions
* Async inner functions
* Closure functions
* Async closure functions
""" """
btn = web.button("test", id="elem_selector_test")
container = get_container()
container.append(btn)
def func(x=1): called = False
# A simple function. call_flag = asyncio.Event()
return x
async def a_func(x=1): @when("click", btn)
# A simple async function. def handler(event):
return x 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(): def make_inner_func():
# Creates a simple inner function. def inner_func(x):
results.append("inner_func")
def inner_func(x=1):
return x
return inner_func return inner_func
def make_inner_a_func(): def make_inner_a_func():
# Creates a simple async inner function. async def inner_a_func(x):
results.append("inner_a_func")
async def inner_a_func(x=1):
return x
return inner_a_func return inner_a_func
def make_closure(): def make_closure():
# Creates a simple closure function.
a = 1 a = 1
def closure_func(x=1): def closure_func(x):
return a + x results.append(f"closure_func:{a}")
return closure_func return closure_func
def make_a_closure(): def make_a_closure():
# Creates a simple async closure function.
a = 1 a = 1
async def closure_a_func(x=1): async def closure_a_func(x):
return a + x results.append(f"closure_a_func:{a}")
return closure_a_func return closure_a_func
inner_func = make_inner_func() inner_func = make_inner_func()
inner_a_func = make_inner_a_func() inner_a_func = make_inner_a_func()
cl_func = make_closure() cl_func = make_closure()
cl_a_func = make_a_closure() cl_a_func = make_a_closure()
whenable = Event() whenable = Event()
# Each of these should work with the when function. # Each of these should work with the @when decorator.
when(whenable, func) when(whenable)(func)
when(whenable, a_func) when(whenable)(a_func)
when(whenable, inner_func) when(whenable)(inner_func)
when(whenable, inner_a_func) when(whenable)(inner_a_func)
when(whenable, cl_func) when(whenable)(cl_func)
when(whenable, cl_a_func) when(whenable)(cl_a_func)
# If we get here, everything worked.
assert True # 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

View File

@@ -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 from pyscript import fetch
@@ -18,6 +19,17 @@ async def test_fetch_json():
assert data["completed"] is False 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(): async def test_fetch_text():
""" """
The fetch function should return the expected text response. The fetch function should return the expected text response.
@@ -31,6 +43,17 @@ async def test_fetch_text():
assert "1" in 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(): async def test_fetch_bytearray():
""" """
The fetch function should return the expected bytearray response. 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") response = await fetch("https://jsonplaceholder.typicode.com/todos/1")
assert response.ok assert response.ok
data = await response.bytearray() data = await response.bytearray()
assert isinstance(data, bytearray)
assert b"delectus aut autem" in data assert b"delectus aut autem" in data
assert b"completed" in data assert b"completed" in data
assert b"false" in data assert b"false" in data
assert b"1" 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(): async def test_fetch_array_buffer():
""" """
The fetch function should return the expected array buffer response. 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_ 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 The fetch function should support direct method chaining for arrayBuffer.
existing URL. """
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") response = await fetch("https://jsonplaceholder.typicode.com/todos/1")
assert response.ok assert response.ok
assert response.status == 200 blob = await response.blob()
data = await response.json() assert blob.size > 0
assert data["userId"] == 1 assert blob.type in ["application/json", "application/json; charset=utf-8"]
assert data["id"] == 1
assert data["title"] == "delectus aut autem"
assert data["completed"] is False
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 The fetch function should support direct method chaining for blob.
non-existent URL. """
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") response = await fetch("https://jsonplaceholder.typicode.com/todos/1000")
assert not response.ok assert not response.ok
assert response.status == 404 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

View File

@@ -1,5 +1,8 @@
""" """
Exercise (as much as is possible) the pyscript.ffi namespace. 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 import upytest
@@ -38,3 +41,85 @@ def test_to_js():
else: else:
from pyodide.ffi import JsProxy from pyodide.ffi import JsProxy
assert isinstance(js_obj, 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

View 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)

View File

@@ -1,4 +1,4 @@
"""" """
Tests for the PyScript media module. Tests for the PyScript media module.
""" """
@@ -7,80 +7,170 @@ import upytest
from pyscript import media from pyscript import media
@upytest.skip( async def test_list_devices_returns_list():
"Uses Pyodide-specific to_js function in MicroPython", """
skip_when=upytest.is_micropython, The list_devices function should return a list of Device objects.
) """
async def test_device_enumeration(): try:
"""Test enumerating media devices.""" 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() 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: if devices:
device = devices[0] device = devices[0]
# Test real device properties exist (but don't assert on their values) # Test all properties exist.
# Browser security might restrict actual values until permissions are granted assert hasattr(device, "id")
assert hasattr(device, "id"), "Device should have id property" assert hasattr(device, "kind")
assert hasattr(device, "kind"), "Device should have kind property" assert hasattr(device, "label")
assert hasattr(device, "group")
# Test kind has valid value.
assert device.kind in [ assert device.kind in [
"videoinput", "videoinput",
"audioinput", "audioinput",
"audiooutput", "audiooutput",
], f"Device should have a valid kind, got: {device.kind}" ]
except Exception:
# Verify dictionary access works with actual device # Permission denied or no devices available - test passes.
assert ( pass
device["id"] == device.id
), "Dictionary access should match property access"
assert ( async def test_device_dict_access():
device["kind"] == device.kind """
), "Dictionary access should match property access" Device objects should support dictionary-style access for JavaScript
interop.
"""
@upytest.skip("Waiting on a bug-fix in MicroPython, for this test to work.", skip_when=upytest.is_micropython) try:
async def test_video_stream_acquisition(): devices = await media.list_devices()
"""Test video stream."""
try: if devices:
# Load a video stream device = devices[0]
stream = await media.Device.load(video=True)
# Dictionary access should match property access.
# Verify we get a real stream with expected properties assert device["id"] == device.id
assert hasattr(stream, "active"), "Stream should have active property" assert device["kind"] == device.kind
assert device["label"] == device.label
# Check for video tracks, but don't fail if permissions aren't granted assert device["group"] == device.group
if stream._dom_element and hasattr(stream._dom_element, "getVideoTracks"): except Exception:
tracks = stream._dom_element.getVideoTracks() # Permission denied or no devices available - test passes.
if tracks.length > 0: pass
assert True, "Video stream has video tracks"
except Exception as e:
# If the browser blocks access, the test should still pass async def test_request_stream_video_only():
# This is because we're testing the API works, not that permissions are granted """
assert ( The request_stream method should return a stream for video only.
True """
), f"Stream acquisition attempted but may require permissions: {str(e)}" try:
stream = await media.Device.request_stream(video=True)
assert hasattr(stream, "active")
@upytest.skip("Waiting on a bug-fix in MicroPython, for this test to work.", skip_when=upytest.is_micropython) except Exception:
async def test_custom_video_constraints(): # Permission denied or no devices available - test passes.
"""Test loading video with custom constraints.""" pass
try:
# Define custom constraints
constraints = {"width": 640, "height": 480} async def test_request_stream_audio_only():
"""
# Load stream with custom constraints The request_stream method should return a stream for audio only.
stream = await media.Device.load(video=constraints) """
try:
# Basic stream property check stream = await media.Device.request_stream(audio=True, video=False)
assert hasattr(stream, "active"), "Stream should have active property" assert hasattr(stream, "active")
except Exception:
# Check for tracks only if we have access # Permission denied or no devices available - test passes.
if stream._dom_element and hasattr(stream._dom_element, "getVideoTracks"): pass
tracks = stream._dom_element.getVideoTracks()
if tracks.length > 0 and hasattr(tracks[0], "getSettings"):
# Settings verification is optional - browsers may handle constraints differently 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 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)}"

View File

@@ -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 from pyscript import Storage, storage
@@ -8,6 +8,9 @@ test_store = None
async def setup(): async def setup():
"""
Set up a clean test storage before each test.
"""
global test_store global test_store
if test_store is None: if test_store is None:
test_store = await storage("test_store") test_store = await storage("test_store")
@@ -16,6 +19,9 @@ async def setup():
async def teardown(): async def teardown():
"""
Clean up test storage after each test.
"""
if test_store: if test_store:
test_store.clear() test_store.clear()
await test_store.sync() await test_store.sync()
@@ -25,17 +31,17 @@ async def test_storage_as_dict():
""" """
The storage object should behave as a Python dict. The storage object should behave as a Python dict.
""" """
# Assign # Assign.
test_store["a"] = 1 test_store["a"] = 1
# Retrieve # Retrieve.
assert test_store["a"] == 1 assert test_store["a"] == 1
assert "a" in test_store assert "a" in test_store
assert len(test_store) == 1 assert len(test_store) == 1
# Iterate # Iterate.
for k, v in test_store.items(): for k, v in test_store.items():
assert k == "a" assert k == "a"
assert v == 1 assert v == 1
# Remove # Remove.
del test_store["a"] del test_store["a"]
assert "a" not in test_store assert "a" not in test_store
assert len(test_store) == 0 assert len(test_store) == 0
@@ -86,3 +92,233 @@ async def test_storage_clear():
assert len(test_store) == 2 assert len(test_store) == 2
test_store.clear() test_store.clear()
assert len(test_store) == 0 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)

View File

@@ -1,3 +1,7 @@
"""
Tests for the pyscript.util module.
"""
import upytest import upytest
import js import js
from pyscript import util from pyscript import util
@@ -5,8 +9,8 @@ from pyscript import util
def test_as_bytearray(): def test_as_bytearray():
""" """
Test the as_bytearray function correctly converts a JavaScript ArrayBuffer The as_bytearray function should convert a JavaScript ArrayBuffer to a
to a Python bytearray. Python bytearray.
""" """
msg = b"Hello, world!" msg = b"Hello, world!"
buffer = js.ArrayBuffer.new(len(msg)) buffer = js.ArrayBuffer.new(len(msg))
@@ -18,31 +22,187 @@ def test_as_bytearray():
assert ba == msg assert ba == msg
def test_not_supported(): def test_as_bytearray_empty():
""" """
Test the NotSupported class raises an exception when trying to access The as_bytearray function should handle empty ArrayBuffers.
attributes or call the object. """
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.") ns = util.NotSupported("test", "This is not supported.")
with upytest.raises(AttributeError) as e: with upytest.raises(AttributeError) as e:
ns.test ns.some_attr
assert str(e.exception) == "This is not supported.", str(e.exception) 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: with upytest.raises(AttributeError) as e:
ns.test = 1 ns.some_attr = 1
assert str(e.exception) == "This is not supported.", str(e.exception) 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: with upytest.raises(TypeError) as e:
ns() 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 The NotSupported class should raise TypeError when called with arguments.
function. """
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(): async def async_func():
yield pass
assert util.is_awaitable(async_func) 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) 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)

View File

@@ -6,6 +6,7 @@ import asyncio
import upytest import upytest
from pyscript import RUNNING_IN_WORKER, document, web, when from pyscript import RUNNING_IN_WORKER, document, web, when
from pyscript.ffi import to_js
def setup(): def setup():
@@ -22,14 +23,22 @@ def test_getitem_by_id():
""" """
An element with an id in the DOM can be retrieved by id. An element with an id in the DOM can be retrieved by id.
""" """
result = web.page.find("#div-no-classes") result = web.page["#div-no-classes"]
# There is a single result.
assert len(result) == 1
# The result is a div. # 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 = [ ids = [
"test_class_selector", "test_class_selector",
"test_selector_w_children", "test_selector_w_children",
@@ -37,35 +46,188 @@ def test_getitem_by_class():
] ]
expected_class = "a-test-class" expected_class = "a-test-class"
result = web.page.find(f".{expected_class}") result = web.page.find(f".{expected_class}")
# EXPECT to find exact number of elements with the class in the page (== 3) # EXPECT to find exact number of elements with the class in the page (== 3)
assert len(result) == 3 assert len(result) == 3
# EXPECT that all element ids are in the expected list # EXPECT that all element ids are in the expected list
assert [el.id for el in result] == ids assert [el.id for el in result] == ids
def test_read_n_write_collection_elements(): 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") elements = web.page.find(".multi-elems")
for element in elements: for element in elements:
assert element.innerHTML == f"Content {element.id.replace('#', '')}" assert element.innerHTML == f"Content {element.id.replace('#', '')}"
new_content = "New Content" new_content = "New Content"
elements.innerHTML = new_content elements.update_all(innerHTML=new_content)
for element in elements: for element in elements:
assert element.innerHTML == new_content 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: class TestElement:
"""
Test the base Element class functionality.
"""
def test_query(self): def test_query(self):
# GIVEN an existing element on the page, with at least 1 child element # GIVEN an existing element on the page, with at least 1 child element
id_ = "test_selector_w_children" 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 # 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 # EXPECT the new element to be associated with the parent
assert ( assert (
@@ -101,7 +263,7 @@ class TestElement:
def test_append_element(self): def test_append_element(self):
id_ = "element-append-tests" id_ = "element-append-tests"
div = web.page.find(f"#{id_}")[0] div = web.page[f"#{id_}"]
len_children_before = len(div.children) len_children_before = len(div.children)
new_el = web.p("new element") new_el = web.p("new element")
div.append(new_el) div.append(new_el)
@@ -110,7 +272,7 @@ class TestElement:
def test_append_dom_element_element(self): def test_append_dom_element_element(self):
id_ = "element-append-tests" id_ = "element-append-tests"
div = web.page.find(f"#{id_}")[0] div = web.page[f"#{id_}"]
len_children_before = len(div.children) len_children_before = len(div.children)
new_el = web.p("new element") new_el = web.p("new element")
div.append(new_el._dom_element) div.append(new_el._dom_element)
@@ -119,7 +281,7 @@ class TestElement:
def test_append_collection(self): def test_append_collection(self):
id_ = "element-append-tests" id_ = "element-append-tests"
div = web.page.find(f"#{id_}")[0] div = web.page[f"#{id_}"]
len_children_before = len(div.children) len_children_before = len(div.children)
collection = web.page.find(".collection") collection = web.page.find(".collection")
div.append(collection) div.append(collection)
@@ -131,19 +293,24 @@ class TestElement:
def test_read_classes(self): def test_read_classes(self):
id_ = "test_class_selector" id_ = "test_class_selector"
expected_class = "a-test-class" expected_class = "a-test-class"
div = web.page.find(f"#{id_}")[0] div = web.page[f"#{id_}"]
assert div.classes == [expected_class] assert div.classes == {expected_class}
def test_add_remove_class(self): def test_add_remove_class(self):
id_ = "div-no-classes" id_ = "div-no-classes"
classname = "tester-class" classname = "tester-class"
div = web.page.find(f"#{id_}")[0] div = web.page[f"#{id_}"]
assert not div.classes assert not div.classes
div.classes.add(classname) div.classes.add(classname)
same_div = web.page.find(f"#{id_}")[0] assert div.classes == {classname}
assert div.classes == [classname] == same_div.classes
div.classes.remove(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): async def test_when_decorator(self):
called = False called = False
@@ -243,6 +410,129 @@ class TestElement:
) )
assert div.textContent == div._dom_element.textContent == "<b>New Content</b>" 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: class TestCollection:
@@ -260,24 +550,6 @@ class TestCollection:
assert el == elements[i] assert el == elements[i]
assert elements[:] == elements 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( @upytest.skip(
"Flakey in Pyodide on Worker", "Flakey in Pyodide on Worker",
skip_when=RUNNING_IN_WORKER and not upytest.is_micropython, skip_when=RUNNING_IN_WORKER and not upytest.is_micropython,
@@ -286,7 +558,7 @@ class TestCollection:
called = False called = False
call_flag = asyncio.Event() call_flag = asyncio.Event()
buttons_collection = web.page["button"] buttons_collection = web.page.find("button")
@when("click", buttons_collection) @when("click", buttons_collection)
def on_click(event): def on_click(event):
@@ -304,27 +576,40 @@ class TestCollection:
called = False called = False
call_flag.clear() call_flag.clear()
async def test_when_decorator_on_event(self): def test_update_all_single_attribute(self):
call_counter = 0 """Test updating single attribute on all elements."""
call_flag = asyncio.Event() div1 = web.div("Content 1")
div2 = web.div("Content 2")
collection = web.ElementCollection([div1, div2])
buttons_collection = web.page.find("button") collection.update_all(className="updated")
number_of_clicks = len(buttons_collection) assert div1.className == "updated"
assert div2.className == "updated"
@when(buttons_collection.on_click) def test_update_all_multiple_attributes(self):
def on_click(event): """Test updating multiple attributes on all elements."""
nonlocal call_counter div1 = web.div()
call_counter += 1 div2 = web.div()
if call_counter == number_of_clicks: collection = web.ElementCollection([div1, div2])
call_flag.set()
# Now let's simulate a click on the button (using the low level JS API) collection.update_all(innerHTML="Hello", title="Test")
# so we don't risk dom getting in the way assert div1.innerHTML == "Hello"
assert call_counter == 0 assert div1.title == "Test"
for button in buttons_collection: assert div2.innerHTML == "Hello"
button._dom_element.click() assert div2.title == "Test"
await call_flag.wait()
assert call_counter == number_of_clicks 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: class TestCreation:
@@ -376,8 +661,7 @@ class TestInput:
def test_value(self): def test_value(self):
for id_ in self.input_ids: for id_ in self.input_ids:
expected_type = id_.split("_")[-1] expected_type = id_.split("_")[-1]
result = web.page.find(f"#{id_}") input_el = web.page[f"#{id_}"]
input_el = result[0]
assert input_el._dom_element.type == expected_type assert input_el._dom_element.type == expected_type
assert ( assert (
input_el.value == f"Content {id_}" == input_el._dom_element.value input_el.value == f"Content {id_}" == input_el._dom_element.value
@@ -388,41 +672,6 @@ class TestInput:
input_el.value = new_value input_el.value = new_value
assert 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: class TestSelect:
@@ -532,7 +781,7 @@ class TestSelect:
def test_select_options_remove(self): def test_select_options_remove(self):
# GIVEN the existing select element with 3 options # 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 # EXPECT the select element to have 3 options
assert len(select.options) == 4 assert len(select.options) == 4
@@ -610,7 +859,7 @@ class TestElements:
kwargs = {k: parse_value(v) for k, v in properties.items()} kwargs = {k: parse_value(v) for k, v in properties.items()}
# Let's make sure the target div to contain the element is empty. # 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 = "" container.innerHTML = ""
assert container.innerHTML == "", container.innerHTML assert container.innerHTML == "", container.innerHTML
@@ -881,7 +1130,9 @@ class TestElements:
assert el._dom_element.tagName == "LABEL" assert el._dom_element.tagName == "LABEL"
assert el.for_ == label_for, "The label should have the correct for attribute." assert el.for_ == label_for, "The label should have the correct for attribute."
# Ensure the label element is rendered with 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): def test_legend(self):
self._create_el_and_basic_asserts("legend", "some text") 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].id == "child2"
assert el.children[1].parentNode.textContent == parent_full_content assert el.children[1].parentNode.textContent == parent_full_content
assert el.children[1].textContent == p2_text_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"

View File

@@ -8,7 +8,13 @@ import upytest
from pyscript import WebSocket 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(): async def test_websocket_with_attributes():
""" """
Event handlers assigned via object attributes. Event handlers assigned via object attributes.
@@ -54,7 +60,7 @@ async def test_websocket_with_attributes():
assert closed_flag is True 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(): async def test_websocket_with_init():
""" """
Event handlers assigned via __init__ arguments. 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 "request served by" in messages[0].lower()
assert messages[1] == "Hello, world!" assert messages[1] == "Hello, world!"
assert closed_flag is True 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

View 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

View 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"]

View File

@@ -7,7 +7,7 @@ declare namespace _default {
"ffi.py": string; "ffi.py": string;
"flatted.py": string; "flatted.py": string;
"fs.py": string; "fs.py": string;
"magic_js.py": string; "context.py": string;
"media.py": string; "media.py": string;
"storage.py": string; "storage.py": string;
"util.py": string; "util.py": string;

View File

@@ -5,5 +5,5 @@ skip = "*.js,*.json"
[tool.ruff] [tool.ruff]
line-length = 114 line-length = 114
lint.select = ["C4", "C90", "E", "EM", "F", "PIE", "PYI", "PLC", "Q", "RET", "W"] 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 lint.mccabe.max-complexity = 27

View File

@@ -1,4 +1,4 @@
black==24.10.0 black==24.10.0
pre-commit==3.7.1 pre-commit==3.7.1
python-minifier==2.11.0 python-minifier==3.1.0
setuptools==72.1.0 setuptools==72.1.0