mirror of
https://github.com/pyscript/pyscript.git
synced 2025-12-20 02:37:41 -05:00
Compare commits
7 Commits
main
...
danyeaw-ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61854bcd14 | ||
|
|
f5bd62a8f6 | ||
|
|
042fb93ef4 | ||
|
|
11e94f4ae9 | ||
|
|
a49f90d67f | ||
|
|
ecd0451582 | ||
|
|
2979b8bfcd |
4
.github/workflows/prepare-release.yml
vendored
4
.github/workflows/prepare-release.yml
vendored
@@ -14,10 +14,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install node
|
- name: Install node
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
|
|
||||||
|
|||||||
24
.github/workflows/publish-release.yml
vendored
24
.github/workflows/publish-release.yml
vendored
@@ -13,13 +13,13 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
contents: write
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install node
|
- name: Install node
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
|
|
||||||
@@ -57,32 +57,22 @@ jobs:
|
|||||||
- name: build
|
- name: build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: build offline
|
|
||||||
run: npm run build:offline
|
|
||||||
|
|
||||||
- name: Rename offline.zip with version metadata
|
|
||||||
run: mv ./dist/offline.zip ./dist/offline_${{ github.ref_name }}.zip
|
|
||||||
|
|
||||||
- name: Generate index.html in snapshot
|
- name: Generate index.html in snapshot
|
||||||
working-directory: .
|
working-directory: .
|
||||||
run: sed -e 's#_PATH_#https://pyscript.net/releases/${{ github.ref_name }}/#g' -e 's#_DOC_VERSION_#${{ github.ref_name }}#g' -e 's#_TAG_VERSION_#/tag/${{ github.ref_name }}#g' -e 's#_VERSION_#${{ github.ref_name }}#g' ./public/index.html > ./core/dist/index.html
|
run: sed -e 's#_PATH_#https://pyscript.net/releases/${{ github.ref_name }}/#' -e 's#_DOC_VERSION_#${{ github.ref_name }}#' -e 's#_TAG_VERSION_#/tag/${{ github.ref_name }}#' -e 's#_VERSION_#${{ github.ref_name }}#' ./public/index.html > ./core/dist/index.html
|
||||||
|
|
||||||
- name: Generate release.tar from snapshot and put it in dist/
|
- name: Generate release.tar from snapshot and put it in dist/
|
||||||
working-directory: .
|
working-directory: .
|
||||||
run: tar -cvf ../release.tar * && mv ../release.tar .
|
run: tar -cvf ../release.tar * && mv ../release.tar .
|
||||||
|
|
||||||
- name: Upload offline.zip to release
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: gh release upload ${{ github.ref_name }} ./dist/offline_${{ github.ref_name }}.zip
|
|
||||||
|
|
||||||
- name: Configure AWS credentials
|
- name: Configure AWS credentials
|
||||||
uses: aws-actions/configure-aws-credentials@v5
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
with:
|
with:
|
||||||
aws-region: ${{ secrets.AWS_REGION }}
|
aws-region: ${{ secrets.AWS_REGION }}
|
||||||
role-to-assume: ${{ secrets.AWS_OIDC_RUNNER_ROLE }}
|
role-to-assume: ${{ secrets.AWS_OIDC_RUNNER_ROLE }}
|
||||||
|
|
||||||
- name: Sync to S3
|
- name: Sync to S3
|
||||||
run:
|
run:
|
||||||
| # Create an explicitly versioned directory under releases/YYYY.MM.MICRO/
|
| # Update /latest and create an explicitly versioned directory under releases/YYYY.MM.MICRO/
|
||||||
|
aws s3 sync --quiet ./dist/ s3://pyscript.net/latest/
|
||||||
aws s3 sync --quiet ./dist/ s3://pyscript.net/releases/${{ github.ref_name }}/
|
aws s3 sync --quiet ./dist/ s3://pyscript.net/releases/${{ github.ref_name }}/
|
||||||
|
|||||||
6
.github/workflows/publish-snapshot.yml
vendored
6
.github/workflows/publish-snapshot.yml
vendored
@@ -20,10 +20,10 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install node
|
- name: Install node
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Configure AWS credentials
|
- name: Configure AWS credentials
|
||||||
uses: aws-actions/configure-aws-credentials@v5
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
with:
|
with:
|
||||||
aws-region: ${{ secrets.AWS_REGION }}
|
aws-region: ${{ secrets.AWS_REGION }}
|
||||||
role-to-assume: ${{ secrets.AWS_OIDC_RUNNER_ROLE }}
|
role-to-assume: ${{ secrets.AWS_OIDC_RUNNER_ROLE }}
|
||||||
|
|||||||
6
.github/workflows/publish-unstable.yml
vendored
6
.github/workflows/publish-unstable.yml
vendored
@@ -21,10 +21,10 @@ jobs:
|
|||||||
working-directory: ./core
|
working-directory: ./core
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install node
|
- name: Install node
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ jobs:
|
|||||||
run: sed -e 's#_PATH_#./#' -e 's#_DOC_VERSION_#latest#' -e 's#_TAG_VERSION_##' -e 's#_VERSION_#latest#' ./public/index.html > ./core/dist/index.html
|
run: sed -e 's#_PATH_#./#' -e 's#_DOC_VERSION_#latest#' -e 's#_TAG_VERSION_##' -e 's#_VERSION_#latest#' ./public/index.html > ./core/dist/index.html
|
||||||
|
|
||||||
- name: Configure AWS credentials
|
- name: Configure AWS credentials
|
||||||
uses: aws-actions/configure-aws-credentials@v5
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
with:
|
with:
|
||||||
aws-region: ${{ secrets.AWS_REGION }}
|
aws-region: ${{ secrets.AWS_REGION }}
|
||||||
role-to-assume: ${{ secrets.AWS_OIDC_RUNNER_ROLE }}
|
role-to-assume: ${{ secrets.AWS_OIDC_RUNNER_ROLE }}
|
||||||
|
|||||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
|||||||
MINICONDA_VERSION: 4.11.0
|
MINICONDA_VERSION: 4.11.0
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 3
|
fetch-depth: 3
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
run: git log --graph -3
|
run: git log --graph -3
|
||||||
|
|
||||||
- name: Install node
|
- name: Install node
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ ci:
|
|||||||
default_stages: [pre-commit]
|
default_stages: [pre-commit]
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v6.0.0
|
rev: v5.0.0
|
||||||
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
|
||||||
@@ -23,8 +24,8 @@ repos:
|
|||||||
exclude: core/dist|\.min\.js$
|
exclude: core/dist|\.min\.js$
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
|
|
||||||
- repo: https://github.com/psf/black-pre-commit-mirror
|
- repo: https://github.com/psf/black
|
||||||
rev: 25.9.0
|
rev: 25.1.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
exclude: core/tests
|
exclude: core/tests
|
||||||
@@ -39,7 +40,7 @@ repos:
|
|||||||
- tomli
|
- tomli
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.13.3
|
rev: v0.9.6
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
exclude: core/tests
|
exclude: core/tests
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
ISSUE_TEMPLATE
|
ISSUE_TEMPLATE
|
||||||
*.min.*
|
*.min.*
|
||||||
package-lock.json
|
package-lock.json
|
||||||
bridge/
|
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -41,8 +41,8 @@ check-python:
|
|||||||
# Check the environment, install the dependencies.
|
# Check the environment, install the dependencies.
|
||||||
setup: check-node check-npm check-python
|
setup: check-node check-npm check-python
|
||||||
cd core && npm ci && cd ..
|
cd core && npm ci && cd ..
|
||||||
ifeq (,$(VIRTUAL_ENV)$(CONDA_PREFIX))
|
ifeq ($(VIRTUAL_ENV),)
|
||||||
echo "\n\n\033[0;31mCannot install Python dependencies. Your virtualenv or conda env is not activated.\033[0m"
|
echo "\n\n\033[0;31mCannot install Python dependencies. Your virtualenv is not activated.\033[0m"
|
||||||
false
|
false
|
||||||
else
|
else
|
||||||
python -m pip install -r requirements.txt
|
python -m pip install -r requirements.txt
|
||||||
|
|||||||
@@ -13,15 +13,15 @@ Using PyScript is as simple as:
|
|||||||
<title>PyScript!</title>
|
<title>PyScript!</title>
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://pyscript.net/releases/2025.11.2/core.css"
|
href="https://pyscript.net/snapshots/2024.9.2/core.css"
|
||||||
/>
|
/>
|
||||||
<script
|
<script
|
||||||
type="module"
|
type="module"
|
||||||
src="https://pyscript.net/releases/2025.11.2/core.js"
|
src="https://pyscript.net/snapshots/2024.9.2/core.js"
|
||||||
></script>
|
></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- type mpy (MicroPython) or py (Pyodide) to run some Python -->
|
<!-- Use MicroPython to evaluate some Python -->
|
||||||
<script type="mpy" terminal>
|
<script type="mpy" terminal>
|
||||||
print("Hello, world!")
|
print("Hello, world!")
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
# @pyscript/bridge
|
|
||||||
|
|
||||||
Import Python utilities directly in JS
|
|
||||||
|
|
||||||
```js
|
|
||||||
// main thread
|
|
||||||
const { ffi: { func_a, func_b } } = await import('./test.js');
|
|
||||||
|
|
||||||
// test.js
|
|
||||||
import bridge from 'https://esm.run/@pyscript/bridge';
|
|
||||||
export const ffi = bridge(import.meta.url, { type: 'mpy', worker: false });
|
|
||||||
|
|
||||||
// test.py
|
|
||||||
def func_a(value):
|
|
||||||
print(f"hello {value}")
|
|
||||||
|
|
||||||
def func_b():
|
|
||||||
import sys
|
|
||||||
return sys.version
|
|
||||||
```
|
|
||||||
|
|
||||||
### Options
|
|
||||||
|
|
||||||
* **pyscript**: the release version to automatically import if not already available on the page. If no version is provided the *developers' channel* version will be used instead (for developers' purposes only).
|
|
||||||
* **type**: `py` by default to bootstrap *Pyodide*.
|
|
||||||
* **worker**: `true` by default to bootstrap in a *Web Worker*.
|
|
||||||
* **config**: either a *string* or a PyScript compatible config *JS literal* to make it possible to bootstrap files and whatnot. If specified, the `worker` becomes implicitly `true` to avoid multiple configs conflicting on the main thread.
|
|
||||||
* **env**: to share the same environment across multiple modules loaded at different times.
|
|
||||||
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
Run `npx mini-coi .` within this folder to then reach out `http://localhost:8080/test/` that will show:
|
|
||||||
|
|
||||||
```
|
|
||||||
PyScript Bridge
|
|
||||||
------------------
|
|
||||||
no config
|
|
||||||
```
|
|
||||||
|
|
||||||
The [test.js](./test/test.js) files uses the following defaults:
|
|
||||||
|
|
||||||
* `pyscript` as `"2025.8.1"`
|
|
||||||
* `type` as `"mpy"`
|
|
||||||
* `worker` as `false`
|
|
||||||
* `config` as `undefined`
|
|
||||||
* `env` as `undefined`
|
|
||||||
|
|
||||||
To test any variant use query string parameters so that `?type=py` will use `py` instead, `worker` will use a worker and `config` will use a basic *config* that brings in another file from the same folder which exposes the version.
|
|
||||||
|
|
||||||
To recap: `http://localhost:8080/test/?type=py&worker&config` will show this instead:
|
|
||||||
|
|
||||||
```
|
|
||||||
PyScript Bridge
|
|
||||||
------------------
|
|
||||||
3.12.7 (main, May 15 2025, 18:47:24) ...
|
|
||||||
```
|
|
||||||
|
|
||||||
Please note when a *config* is used, the `worker` attribute is always `true`.
|
|
||||||
163
bridge/index.js
163
bridge/index.js
@@ -1,163 +0,0 @@
|
|||||||
/*! (c) PyScript Development Team */
|
|
||||||
|
|
||||||
const { stringify } = JSON;
|
|
||||||
const { assign, create, entries } = Object;
|
|
||||||
|
|
||||||
const el = (name, props) => assign(document.createElement(name), props);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform a list of keys into a Python dictionary.
|
|
||||||
* `['a', 'b']` => `{ "a": a, "b": b }`
|
|
||||||
* @param {Iterable<string>} keys
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
const dictionary = keys => {
|
|
||||||
const fields = [];
|
|
||||||
for (const key of keys)
|
|
||||||
fields.push(`${stringify(key)}: ${key}`);
|
|
||||||
return `{ ${fields.join(',')} }`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve properly config files relative URLs.
|
|
||||||
* @param {string|Object} config - The configuration to normalize.
|
|
||||||
* @param {string} base - The base URL to resolve relative URLs against.
|
|
||||||
* @returns {string} - The JSON serialized config.
|
|
||||||
*/
|
|
||||||
const normalize = async (config, base) => {
|
|
||||||
if (typeof config === 'string') {
|
|
||||||
base = config;
|
|
||||||
config = await fetch(config).then(res => res.json());
|
|
||||||
}
|
|
||||||
if (typeof config.files === 'object') {
|
|
||||||
const files = {};
|
|
||||||
for (const [key, value] of entries(config.files)) {
|
|
||||||
files[key.startsWith('{') ? key : new URL(key, base)] = value;
|
|
||||||
}
|
|
||||||
config.files = files;
|
|
||||||
}
|
|
||||||
return stringify(config);
|
|
||||||
};
|
|
||||||
|
|
||||||
// this logic is based on a 3 levels cache ...
|
|
||||||
const cache = new Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a bridge to a Python module via a `.js` file that has a `.py` alter ego.
|
|
||||||
* @param {string} url - The URL of the JS module that has a Python counterpart.
|
|
||||||
* @param {Object} options - The options for the bridge.
|
|
||||||
* @param {string} [options.type='py'] - The `py` or `mpy` interpreter type, `py` by default.
|
|
||||||
* @param {boolean} [options.worker=true] - Whether to use a worker, `true` by default.
|
|
||||||
* @param {string|Object} [options.config=null] - The configuration for the bridge, `null` by default.
|
|
||||||
* @param {string} [options.env=null] - The optional shared environment to use.
|
|
||||||
* @param {string} [options.serviceWorker=null] - The optional service worker to use as fallback.
|
|
||||||
* @returns {Object} - The bridge to the Python module.
|
|
||||||
*/
|
|
||||||
export default (url, {
|
|
||||||
type = 'py',
|
|
||||||
worker = true,
|
|
||||||
config = null,
|
|
||||||
env = null,
|
|
||||||
serviceWorker = null,
|
|
||||||
pyscript = null,
|
|
||||||
} = {}) => {
|
|
||||||
const { protocol, host, pathname } = new URL(url);
|
|
||||||
const py = pathname.replace(/\.m?js(?:\/\+\w+)?$/, '.py');
|
|
||||||
const file = `${protocol}//${host}${py}`;
|
|
||||||
|
|
||||||
// the first cache is about the desired file in the wild ...
|
|
||||||
if (!cache.has(file)) {
|
|
||||||
// the second cache is about all fields one needs to access out there
|
|
||||||
const exports = new Map;
|
|
||||||
let python;
|
|
||||||
|
|
||||||
cache.set(file, new Proxy(create(null), {
|
|
||||||
get(_, field) {
|
|
||||||
if (!exports.has(field)) {
|
|
||||||
// create an async callback once and always return the same later on
|
|
||||||
exports.set(field, async (...args) => {
|
|
||||||
// the third cache is about reaching lazily the code only once
|
|
||||||
// augmenting its content with exports once and drop it on done
|
|
||||||
if (!python) {
|
|
||||||
// do not await or multiple calls will fetch multiple times
|
|
||||||
// just assign the fetch `Promise` once and return it
|
|
||||||
python = fetch(file).then(async response => {
|
|
||||||
const code = await response.text();
|
|
||||||
// create a unique identifier for the Python context
|
|
||||||
const identifier = pathname.replace(/[^a-zA-Z0-9_]/g, '');
|
|
||||||
const name = `__pyscript_${identifier}${Date.now()}`;
|
|
||||||
// create a Python dictionary with all accessed fields
|
|
||||||
const detail = `{"detail":${dictionary(exports.keys())}}`;
|
|
||||||
// create the arguments for the `dispatchEvent` call
|
|
||||||
const eventArgs = `${stringify(name)},${name}to_ts(${detail})`;
|
|
||||||
// bootstrap the script element type and its attributes
|
|
||||||
const script = el('script', { type, textContent: [
|
|
||||||
'\n', code, '\n',
|
|
||||||
// this is to avoid local scope name clashing
|
|
||||||
`from pyscript import window as ${name}`,
|
|
||||||
`from pyscript.ffi import to_js as ${name}to_ts`,
|
|
||||||
`${name}.dispatchEvent(${name}.CustomEvent.new(${eventArgs}))`,
|
|
||||||
// remove these references even if non-clashing to keep
|
|
||||||
// the local scope clean from undesired entries
|
|
||||||
`del ${name}`,
|
|
||||||
`del ${name}to_ts`,
|
|
||||||
].join('\n') });
|
|
||||||
|
|
||||||
// if config is provided it needs to be a worker to avoid
|
|
||||||
// conflicting with main config on the main thread (just like always)
|
|
||||||
script.toggleAttribute('worker', !!config || !!worker);
|
|
||||||
if (config) {
|
|
||||||
const attribute = await normalize(config, file);
|
|
||||||
script.setAttribute('config', attribute);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (env) script.setAttribute('env', env);
|
|
||||||
if (serviceWorker) script.setAttribute('service-worker', serviceWorker);
|
|
||||||
|
|
||||||
// let PyScript resolve and execute this script
|
|
||||||
document.body.appendChild(script);
|
|
||||||
|
|
||||||
// intercept once the unique event identifier with all exports
|
|
||||||
globalThis.addEventListener(
|
|
||||||
name,
|
|
||||||
event => {
|
|
||||||
resolve(event.detail);
|
|
||||||
script.remove();
|
|
||||||
},
|
|
||||||
{ once: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
// return a promise that will resolve only once the event
|
|
||||||
// has been emitted and the interpreter evaluated the code
|
|
||||||
const { promise, resolve } = Promise.withResolvers();
|
|
||||||
|
|
||||||
if (!(Symbol.for('@pyscript/core') in globalThis)) {
|
|
||||||
// bring in PyScript if not available already
|
|
||||||
const cdn = pyscript ?
|
|
||||||
`https://pyscript.net/releases/${pyscript}` :
|
|
||||||
// ⚠️ fallback to developers' channel !!!
|
|
||||||
'https://cdn.jsdelivr.net/npm/@pyscript/core/dist'
|
|
||||||
;
|
|
||||||
document.head.appendChild(
|
|
||||||
el('link', { rel: 'stylesheet', href: `${cdn}/core.css` }),
|
|
||||||
);
|
|
||||||
try { await import(`${cdn}/core.js`) }
|
|
||||||
catch {}
|
|
||||||
}
|
|
||||||
return promise;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// return the `Promise` that will after invoke the exported field
|
|
||||||
return python.then(foreign => foreign[field](...args));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// return the lazily to be resolved once callback to invoke
|
|
||||||
return exports.get(field);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return cache.get(file);
|
|
||||||
};
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@pyscript/bridge",
|
|
||||||
"version": "0.2.2",
|
|
||||||
"description": "A JS based way to use PyScript modules",
|
|
||||||
"type": "module",
|
|
||||||
"module": "./index.js",
|
|
||||||
"unpkg": "./index.js",
|
|
||||||
"jsdelivr": "./jsdelivr.js",
|
|
||||||
"browser": "./index.js",
|
|
||||||
"main": "./index.js",
|
|
||||||
"keywords": [
|
|
||||||
"PyScript",
|
|
||||||
"JS",
|
|
||||||
"Python",
|
|
||||||
"bridge"
|
|
||||||
],
|
|
||||||
"files": [
|
|
||||||
"index.js",
|
|
||||||
"README.md"
|
|
||||||
],
|
|
||||||
"author": "Anaconda Inc.",
|
|
||||||
"license": "APACHE-2.0",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "git+https://github.com/pyscript/pyscript.git"
|
|
||||||
},
|
|
||||||
"bugs": {
|
|
||||||
"url": "https://github.com/pyscript/pyscript/issues"
|
|
||||||
},
|
|
||||||
"homepage": "https://github.com/pyscript/pyscript#readme"
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>PyScript Bridge</title>
|
|
||||||
<style>body { font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; }</style>
|
|
||||||
<!-- for local testing purpose only-->
|
|
||||||
<script type="importmap">{"imports":{"https://esm.run/@pyscript/bridge":"../index.js"}}</script>
|
|
||||||
<script type="module">
|
|
||||||
const { ffi: { test_func, test_other, version } } = await import('./test.js');
|
|
||||||
|
|
||||||
console.time("⏱️ first invoke");
|
|
||||||
const result = await test_func("PyScript Bridge");
|
|
||||||
console.timeEnd("⏱️ first invoke");
|
|
||||||
|
|
||||||
document.body.append(
|
|
||||||
Object.assign(
|
|
||||||
document.createElement("h3"),
|
|
||||||
{ textContent: result },
|
|
||||||
),
|
|
||||||
document.createElement("hr"),
|
|
||||||
await version(),
|
|
||||||
);
|
|
||||||
|
|
||||||
console.time("⏱️ other invokes");
|
|
||||||
await test_other("🐍");
|
|
||||||
console.timeEnd("⏱️ other invokes");
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
</html>
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>PyScript Bridge</title>
|
|
||||||
<script type="importmap">
|
|
||||||
{
|
|
||||||
"imports": {
|
|
||||||
"https://esm.run/@pyscript/bridge": "https://esm.run/@pyscript/bridge@latest",
|
|
||||||
"https://esm.run/@pyscript/bridge/test/test.js": "https://esm.run/@pyscript/bridge@latest/test/test.js"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style>body { font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; }</style>
|
|
||||||
<link rel="stylesheet" href="https://pyscript.net/releases/2025.5.1/core.css" />
|
|
||||||
<script type="module" src="https://pyscript.net/releases/2025.5.1/core.js"></script>
|
|
||||||
<script type="module">
|
|
||||||
const cdn_test = 'https://esm.run/@pyscript/bridge/test/test.js';
|
|
||||||
const { ffi: { test_func, test_other, version } } = await import(cdn_test);
|
|
||||||
|
|
||||||
console.time("⏱️ first invoke");
|
|
||||||
const result = await test_func("PyScript Bridge");
|
|
||||||
console.timeEnd("⏱️ first invoke");
|
|
||||||
|
|
||||||
document.body.append(
|
|
||||||
Object.assign(
|
|
||||||
document.createElement("h3"),
|
|
||||||
{ textContent: result },
|
|
||||||
),
|
|
||||||
document.createElement("hr"),
|
|
||||||
await version(),
|
|
||||||
);
|
|
||||||
|
|
||||||
console.time("⏱️ other invokes");
|
|
||||||
await test_other("🐍");
|
|
||||||
console.timeEnd("⏱️ other invokes");
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
</html>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
def version():
|
|
||||||
return sys.version
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import bridge from "https://esm.run/@pyscript/bridge";
|
|
||||||
|
|
||||||
// for local testing purpose only
|
|
||||||
const { searchParams } = new URL(location.href);
|
|
||||||
|
|
||||||
// the named (or default) export for test.py
|
|
||||||
export const ffi = bridge(import.meta.url, {
|
|
||||||
pyscript: "2025.8.1",
|
|
||||||
env: searchParams.get("env"),
|
|
||||||
type: searchParams.get("type") || "mpy",
|
|
||||||
worker: searchParams.has("worker"),
|
|
||||||
config: searchParams.has("config") ?
|
|
||||||
({
|
|
||||||
files: {
|
|
||||||
"./sys_version.py": "./sys_version.py",
|
|
||||||
},
|
|
||||||
}) : undefined,
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
from pyscript import config, RUNNING_IN_WORKER
|
|
||||||
|
|
||||||
type = config["type"]
|
|
||||||
print(f"{type}-script", RUNNING_IN_WORKER and "worker" or "main")
|
|
||||||
|
|
||||||
|
|
||||||
def test_func(message):
|
|
||||||
print("Python", message)
|
|
||||||
return message
|
|
||||||
|
|
||||||
|
|
||||||
def test_other(message):
|
|
||||||
print("Python", message)
|
|
||||||
return message
|
|
||||||
|
|
||||||
|
|
||||||
def version():
|
|
||||||
try:
|
|
||||||
from sys_version import version
|
|
||||||
except ImportError:
|
|
||||||
version = lambda: "no config"
|
|
||||||
return version()
|
|
||||||
205
core/LICENSE
205
core/LICENSE
@@ -1,205 +0,0 @@
|
|||||||
Apache License
|
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
||||||
|
|
||||||
1. Definitions.
|
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
|
||||||
except as required for reasonable and customary use in describing the
|
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
||||||
replaced with your own identifying information. (Don't include
|
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
|
|
||||||
Copyright (c) 2022-present, PyScript Development Team
|
|
||||||
|
|
||||||
Originated at Anaconda, Inc. in 2022
|
|
||||||
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
954
core/package-lock.json
generated
954
core/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@pyscript/core",
|
"name": "@pyscript/core",
|
||||||
"version": "0.7.11",
|
"version": "0.6.39",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "PyScript",
|
"description": "PyScript",
|
||||||
"module": "./index.js",
|
"module": "./index.js",
|
||||||
@@ -35,20 +35,16 @@
|
|||||||
"./storage": {
|
"./storage": {
|
||||||
"import": "./dist/storage.js"
|
"import": "./dist/storage.js"
|
||||||
},
|
},
|
||||||
"./service-worker": {
|
|
||||||
"import": "./dist/service-worker.js"
|
|
||||||
},
|
|
||||||
"./package.json": "./package.json"
|
"./package.json": "./package.json"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"server": "echo \"➡️ TESTS @ $(tput bold)http://localhost:8080/tests/$(tput sgr0)\"; npx static-handler --coi .",
|
"server": "echo \"➡️ TESTS @ $(tput bold)http://localhost:8080/tests/$(tput sgr0)\"; npx static-handler --coi .",
|
||||||
"build": "export ESLINT_USE_FLAT_CONFIG=true;npm run build:3rd-party && npm run build:stdlib && npm run build:plugins && npm run build:core && npm run build:tests-index && if [ -z \"$NO_MIN\" ]; then eslint src/ && npm run test:integration; fi",
|
"build": "export ESLINT_USE_FLAT_CONFIG=true;npm run build:3rd-party && npm run build:stdlib && npm run build:plugins && npm run build:core && npm run build:tests-index && if [ -z \"$NO_MIN\" ]; then eslint src/ && npm run ts && npm run test:integration; fi",
|
||||||
"build:core": "rm -rf dist && rollup --config rollup/core.config.js && cp src/3rd-party/*.css dist/",
|
"build:core": "rm -rf dist && rollup --config rollup/core.config.js && cp src/3rd-party/*.css dist/",
|
||||||
"build:flatted": "node rollup/flatted.cjs",
|
"build:flatted": "node rollup/flatted.cjs",
|
||||||
"build:plugins": "node rollup/plugins.cjs",
|
"build:plugins": "node rollup/plugins.cjs",
|
||||||
"build:stdlib": "node rollup/stdlib.cjs",
|
"build:stdlib": "node rollup/stdlib.cjs",
|
||||||
"build:3rd-party": "node rollup/3rd-party.cjs",
|
"build:3rd-party": "node rollup/3rd-party.cjs",
|
||||||
"build:offline": "node rollup/offline.cjs | bash",
|
|
||||||
"build:tests-index": "node rollup/build_test_index.cjs",
|
"build:tests-index": "node rollup/build_test_index.cjs",
|
||||||
"clean:3rd-party": "rm src/3rd-party/*.js && rm src/3rd-party/*.css",
|
"clean:3rd-party": "rm src/3rd-party/*.js && rm src/3rd-party/*.css",
|
||||||
"test:integration": "npm run test:ws; static-handler --coi . 2>/dev/null & SH_PID=$!; EXIT_CODE=0; (playwright test tests/js_tests.spec.js && playwright test tests/py_tests.main.spec.js && playwright test tests/py_tests.worker.spec.js) || EXIT_CODE=$?; kill $SH_PID 2>/dev/null; exit $EXIT_CODE",
|
"test:integration": "npm run test:ws; static-handler --coi . 2>/dev/null & SH_PID=$!; EXIT_CODE=0; (playwright test tests/js_tests.spec.js && playwright test tests/py_tests.main.spec.js && playwright test tests/py_tests.worker.spec.js) || EXIT_CODE=$?; kill $SH_PID 2>/dev/null; exit $EXIT_CODE",
|
||||||
@@ -68,40 +64,40 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ungap/with-resolvers": "^0.1.0",
|
"@ungap/with-resolvers": "^0.1.0",
|
||||||
"@webreflection/idb-map": "^0.3.2",
|
"@webreflection/idb-map": "^0.3.2",
|
||||||
"@webreflection/utils": "^0.1.1",
|
|
||||||
"add-promise-listener": "^0.1.3",
|
"add-promise-listener": "^0.1.3",
|
||||||
"basic-devtools": "^0.1.6",
|
"basic-devtools": "^0.1.6",
|
||||||
"polyscript": "^0.20.0",
|
"polyscript": "^0.16.21",
|
||||||
|
"sabayon": "^0.6.6",
|
||||||
"sticky-module": "^0.1.1",
|
"sticky-module": "^0.1.1",
|
||||||
"to-json-callback": "^0.1.1",
|
"to-json-callback": "^0.1.1",
|
||||||
"type-checked-collections": "^0.1.7"
|
"type-checked-collections": "^0.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@codemirror/commands": "^6.10.0",
|
"@codemirror/commands": "^6.8.0",
|
||||||
"@codemirror/lang-python": "^6.2.1",
|
"@codemirror/lang-python": "^6.1.7",
|
||||||
"@codemirror/language": "^6.11.3",
|
"@codemirror/language": "^6.10.8",
|
||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
"@codemirror/view": "^6.38.8",
|
"@codemirror/view": "^6.36.4",
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.51.0",
|
||||||
"@rollup/plugin-commonjs": "^29.0.0",
|
"@rollup/plugin-commonjs": "^28.0.3",
|
||||||
"@rollup/plugin-node-resolve": "^16.0.3",
|
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||||
"@rollup/plugin-terser": "^0.4.4",
|
"@rollup/plugin-terser": "^0.4.4",
|
||||||
"@webreflection/toml-j0.4": "^1.1.4",
|
"@webreflection/toml-j0.4": "^1.1.3",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"bun": "^1.3.3",
|
"bun": "^1.2.4",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
"codedent": "^0.1.2",
|
"codedent": "^0.1.2",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.1",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.22.0",
|
||||||
"flatted": "^3.3.3",
|
"flatted": "^3.3.3",
|
||||||
"rollup": "^4.53.3",
|
"rollup": "^4.35.0",
|
||||||
"rollup-plugin-postcss": "^4.0.2",
|
"rollup-plugin-postcss": "^4.0.2",
|
||||||
"rollup-plugin-string": "^3.0.0",
|
"rollup-plugin-string": "^3.0.0",
|
||||||
"static-handler": "^0.5.3",
|
"static-handler": "^0.5.3",
|
||||||
"string-width": "^8.1.0",
|
"string-width": "^7.2.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.8.2",
|
||||||
"xterm-readline": "^1.1.2"
|
"xterm-readline": "^1.1.2"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
# This script assumes the following folder structure:
|
|
||||||
# ./pyscript - it must be a GitHub clone/fork
|
|
||||||
# ./polyscript - it must be a GitHub clone/fork
|
|
||||||
#
|
|
||||||
# Running from ./pyscript/core via:
|
|
||||||
#
|
|
||||||
# cd ./pyscript/core
|
|
||||||
# bash ./pyodide.sh
|
|
||||||
#
|
|
||||||
# will print a JSON compatible string like:
|
|
||||||
#
|
|
||||||
# {
|
|
||||||
# "2024.10.1": "0.26.2",
|
|
||||||
# ...
|
|
||||||
# "2025.11.1": "0.29.0",
|
|
||||||
# "": null
|
|
||||||
# }
|
|
||||||
#
|
|
||||||
# Each key represents the PyScript release and each
|
|
||||||
# value represents the Pyodide version used by that PyScript release.
|
|
||||||
#
|
|
||||||
# The last empty key with `null` value is used just to close the JSON object.
|
|
||||||
# One could remove manually that entry as long as there are no dangling commas.
|
|
||||||
#
|
|
||||||
|
|
||||||
current_pyscript=$(git branch | grep \\* | cut -d ' ' -f2)
|
|
||||||
|
|
||||||
echo "{"
|
|
||||||
for release in $(git tag --list --sort=version:refname); do
|
|
||||||
git checkout ${release} > /dev/null 2>&1
|
|
||||||
if test -e "package.json"; then
|
|
||||||
polyscript=$(cat package.json | jq -r '.dependencies.polyscript')
|
|
||||||
tag="v${polyscript:1:${#polyscript}-1}"
|
|
||||||
cd ../../polyscript > /dev/null 2>&1
|
|
||||||
current_polyscript=$(git branch | grep \\* | cut -d ' ' -f2)
|
|
||||||
git checkout ${tag} > /dev/null 2>&1
|
|
||||||
if test -e "versions/pyodide"; then
|
|
||||||
echo " \"${release}\": \"$(cat versions/pyodide)\","
|
|
||||||
fi
|
|
||||||
git checkout ${current_polyscript} > /dev/null 2>&1
|
|
||||||
cd - > /dev/null 2>&1
|
|
||||||
fi
|
|
||||||
git checkout ${current_pyscript} > /dev/null 2>&1
|
|
||||||
done
|
|
||||||
echo " \"\": null"
|
|
||||||
echo "}"
|
|
||||||
@@ -53,15 +53,4 @@ export default [
|
|||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
input: "./src/service-worker.js",
|
|
||||||
plugins: plugins.concat(
|
|
||||||
process.env.NO_MIN
|
|
||||||
? [nodeResolve(), commonjs()]
|
|
||||||
: [nodeResolve(), commonjs(), terser()],
|
|
||||||
),
|
|
||||||
output: {
|
|
||||||
file: "./dist/service-worker.js",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
const { readFileSync, writeFileSync } = require("node:fs");
|
|
||||||
const { join, resolve } = require("node:path");
|
|
||||||
|
|
||||||
const versions = resolve(
|
|
||||||
__dirname,
|
|
||||||
"..",
|
|
||||||
"node_modules",
|
|
||||||
"polyscript",
|
|
||||||
"versions",
|
|
||||||
);
|
|
||||||
let pyodide = String(readFileSync(join(versions, "pyodide"), "utf8")).trim();
|
|
||||||
let micropython = String(
|
|
||||||
readFileSync(join(versions, "micropython"), "utf8"),
|
|
||||||
).trim();
|
|
||||||
|
|
||||||
writeFileSync(
|
|
||||||
join(process.cwd(), "offline.html"),
|
|
||||||
`<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>PyScript Offline</title>
|
|
||||||
<script src="./mini-coi-fd.js"></script>
|
|
||||||
<script type="module" src="./pyscript/core.js" offline></script>
|
|
||||||
<link rel="stylesheet" href="./pyscript/core.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<script type="mpy">
|
|
||||||
from pyscript import document
|
|
||||||
|
|
||||||
document.body.append("MicroPython Offline", document.createElement("hr"))
|
|
||||||
</script>
|
|
||||||
<script type="py" worker>
|
|
||||||
from pyscript import document
|
|
||||||
|
|
||||||
document.body.append("Pyodide Offline")
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`,
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
let bash = `#!/usr/bin/env bash
|
|
||||||
rm -rf dist/offline
|
|
||||||
|
|
||||||
mkdir -p dist/offline/node_modules
|
|
||||||
echo '{"dependencies":{"pyodide":"${pyodide}","@micropython/micropython-webassembly-pyscript":"${micropython}"}}' > dist/offline/package.json
|
|
||||||
cd dist/offline
|
|
||||||
curl -sLO https://raw.githubusercontent.com/WebReflection/mini-coi/refs/heads/main/mini-coi-fd.js
|
|
||||||
npm i
|
|
||||||
cd -
|
|
||||||
|
|
||||||
mkdir -p dist/offline/pyscript/pyodide
|
|
||||||
cd dist/offline/pyscript/pyodide
|
|
||||||
cp ../../node_modules/pyodide/pyodide* ./
|
|
||||||
cp ../../node_modules/pyodide/python_stdlib.zip ./
|
|
||||||
cd -
|
|
||||||
|
|
||||||
mkdir -p dist/offline/pyscript/micropython
|
|
||||||
cd dist/offline/pyscript/micropython
|
|
||||||
cp ../../node_modules/@micropython/micropython-webassembly-pyscript/micropython.* ./
|
|
||||||
cd -
|
|
||||||
|
|
||||||
rm -rf dist/offline/node_modules
|
|
||||||
rm -rf dist/offline/*.json
|
|
||||||
|
|
||||||
mv offline.html dist/offline/index.html
|
|
||||||
cp dist/*.* dist/offline/pyscript/
|
|
||||||
rm -f dist/offline/pyscript/offline.zip
|
|
||||||
|
|
||||||
cd dist
|
|
||||||
zip -r offline.zip offline
|
|
||||||
rm -rf offline
|
|
||||||
cd -
|
|
||||||
`;
|
|
||||||
|
|
||||||
console.log(bash);
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijnh@gmail.com> and others
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
|
||||||
all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
THE SOFTWARE.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
|
||||||
all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
THE SOFTWARE.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
|
||||||
all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
THE SOFTWARE.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
|
||||||
all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
THE SOFTWARE.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
|
||||||
all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
THE SOFTWARE.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
|
||||||
all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
THE SOFTWARE.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
The MIT License (MIT)
|
|
||||||
|
|
||||||
Copyright (c) 2015 Jak Wings
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
Copyright 2021 Erik Bremen
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any
|
|
||||||
person obtaining a copy of this software and associated
|
|
||||||
documentation files (the "Software"), to deal in the
|
|
||||||
Software without restriction, including without
|
|
||||||
limitation the rights to use, copy, modify, merge,
|
|
||||||
publish, distribute, sublicense, and/or sell copies of
|
|
||||||
the Software, and to permit persons to whom the Software
|
|
||||||
is furnished to do so, subject to the following
|
|
||||||
conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice
|
|
||||||
shall be included in all copies or substantial portions
|
|
||||||
of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
|
||||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
|
||||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
|
||||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
||||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
||||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
|
||||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
||||||
DEALINGS IN THE SOFTWARE.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
Copyright (c) 2017-2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)
|
|
||||||
Copyright (c) 2014-2016, SourceLair Private Company (https://www.sourcelair.com)
|
|
||||||
Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/)
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
|
||||||
all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
THE SOFTWARE.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
Copyright (c) 2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
|
||||||
all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
THE SOFTWARE.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
Copyright (c) 2017, The xterm.js authors (https://github.com/xtermjs/xterm.js)
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
|
||||||
all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
THE SOFTWARE.
|
|
||||||
4
core/src/3rd-party/README.md
vendored
4
core/src/3rd-party/README.md
vendored
@@ -5,7 +5,3 @@ This folder contains artifacts created via [3rd-party.cjs](../../rollup/3rd-part
|
|||||||
As we would like to offer a way to run PyScript offline, and we already offer a `dist` folder with all the necessary scripts, we have created a foreign dependencies resolver that allow to lazy-load CDN dependencies out of the box.
|
As we would like to offer a way to run PyScript offline, and we already offer a `dist` folder with all the necessary scripts, we have created a foreign dependencies resolver that allow to lazy-load CDN dependencies out of the box.
|
||||||
|
|
||||||
Please **note** these dependencies are **not interpreters**, because interpreters have their own mechanism, folders structure, WASM files, and whatnot, to work locally, but at least XTerm or the TOML parser, among other lazy dependencies, should be available within the dist folder.
|
Please **note** these dependencies are **not interpreters**, because interpreters have their own mechanism, folders structure, WASM files, and whatnot, to work locally, but at least XTerm or the TOML parser, among other lazy dependencies, should be available within the dist folder.
|
||||||
|
|
||||||
## Licenses
|
|
||||||
|
|
||||||
All licenses provided by 3rd-party authors can be found in [3rd-party-licenses](../3rd-party-licenses/) folder.
|
|
||||||
|
|||||||
2
core/src/3rd-party/xterm-readline.js
vendored
2
core/src/3rd-party/xterm-readline.js
vendored
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Bundled by jsDelivr using Rollup v2.79.2 and Terser v5.39.0.
|
* Bundled by jsDelivr using Rollup v2.79.1 and Terser v5.19.2.
|
||||||
* Original file: /npm/xterm-readline@1.1.2/lib/readline.js
|
* Original file: /npm/xterm-readline@1.1.2/lib/readline.js
|
||||||
*
|
*
|
||||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||||
|
|||||||
2
core/src/3rd-party/xterm.css
vendored
2
core/src/3rd-party/xterm.css
vendored
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Minified by jsDelivr using clean-css v5.3.3.
|
* Minified by jsDelivr using clean-css v5.3.2.
|
||||||
* Original file: /npm/@xterm/xterm@5.5.0/css/xterm.css
|
* Original file: /npm/@xterm/xterm@5.5.0/css/xterm.css
|
||||||
*
|
*
|
||||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||||
|
|||||||
4
core/src/3rd-party/xterm.js
vendored
4
core/src/3rd-party/xterm.js
vendored
File diff suppressed because one or more lines are too long
2
core/src/3rd-party/xterm_addon-fit.js
vendored
2
core/src/3rd-party/xterm_addon-fit.js
vendored
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Bundled by jsDelivr using Rollup v2.79.2 and Terser v5.39.0.
|
* Bundled by jsDelivr using Rollup v2.79.1 and Terser v5.19.2.
|
||||||
* Original file: /npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js
|
* Original file: /npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js
|
||||||
*
|
*
|
||||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||||
|
|||||||
2
core/src/3rd-party/xterm_addon-web-links.js
vendored
2
core/src/3rd-party/xterm_addon-web-links.js
vendored
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Bundled by jsDelivr using Rollup v2.79.2 and Terser v5.39.0.
|
* Bundled by jsDelivr using Rollup v2.79.1 and Terser v5.19.2.
|
||||||
* Original file: /npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.js
|
* Original file: /npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.js
|
||||||
*
|
*
|
||||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import withResolvers from "@webreflection/utils/with-resolvers";
|
|
||||||
import TYPES from "./types.js";
|
import TYPES from "./types.js";
|
||||||
|
|
||||||
const waitForIt = [];
|
const waitForIt = [];
|
||||||
@@ -6,7 +5,7 @@ const waitForIt = [];
|
|||||||
for (const [TYPE] of TYPES) {
|
for (const [TYPE] of TYPES) {
|
||||||
const selectors = [`script[type="${TYPE}"]`, `${TYPE}-script`];
|
const selectors = [`script[type="${TYPE}"]`, `${TYPE}-script`];
|
||||||
for (const element of document.querySelectorAll(selectors.join(","))) {
|
for (const element of document.querySelectorAll(selectors.join(","))) {
|
||||||
const { promise, resolve } = withResolvers();
|
const { promise, resolve } = Promise.withResolvers();
|
||||||
waitForIt.push(promise);
|
waitForIt.push(promise);
|
||||||
element.addEventListener(`${TYPE}:done`, resolve, { once: true });
|
element.addEventListener(`${TYPE}:done`, resolve, { once: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ for (const [TYPE] of TYPES) {
|
|||||||
|
|
||||||
let config,
|
let config,
|
||||||
type,
|
type,
|
||||||
parser,
|
|
||||||
pyElement,
|
pyElement,
|
||||||
pyConfigs = $$(`${TYPE}-config`),
|
pyConfigs = $$(`${TYPE}-config`),
|
||||||
attrConfigs = $$(
|
attrConfigs = $$(
|
||||||
@@ -93,11 +92,9 @@ for (const [TYPE] of TYPES) {
|
|||||||
[pyElement] = pyConfigs;
|
[pyElement] = pyConfigs;
|
||||||
config = pyElement.getAttribute("src") || pyElement.textContent;
|
config = pyElement.getAttribute("src") || pyElement.textContent;
|
||||||
type = pyElement.getAttribute("type");
|
type = pyElement.getAttribute("type");
|
||||||
parser = pyElement.getAttribute("config-parser");
|
|
||||||
} else if (attrConfigs.length) {
|
} else if (attrConfigs.length) {
|
||||||
[pyElement, ...attrConfigs] = attrConfigs;
|
[pyElement, ...attrConfigs] = attrConfigs;
|
||||||
config = pyElement.getAttribute("config");
|
config = pyElement.getAttribute("config");
|
||||||
parser = pyElement.getAttribute("config-parser");
|
|
||||||
// throw an error if dirrent scripts use different configs
|
// throw an error if dirrent scripts use different configs
|
||||||
if (
|
if (
|
||||||
attrConfigs.some((el) => el.getAttribute("config") !== config)
|
attrConfigs.some((el) => el.getAttribute("config") !== config)
|
||||||
@@ -123,12 +120,9 @@ for (const [TYPE] of TYPES) {
|
|||||||
}
|
}
|
||||||
} else if (toml || type === "toml") {
|
} else if (toml || type === "toml") {
|
||||||
try {
|
try {
|
||||||
const module = parser
|
const { parse } = await import(
|
||||||
? await import(parser)
|
/* webpackIgnore: true */ "./3rd-party/toml.js"
|
||||||
: await import(
|
);
|
||||||
/* webpackIgnore: true */ "./3rd-party/toml.js"
|
|
||||||
);
|
|
||||||
const parse = module.parse || module.default;
|
|
||||||
parsed = parse(text);
|
parsed = parse(text);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = syntaxError("TOML", url, e);
|
error = syntaxError("TOML", url, e);
|
||||||
@@ -160,9 +154,6 @@ for (const [TYPE] of TYPES) {
|
|||||||
return await Promise.all(toBeAwaited);
|
return await Promise.all(toBeAwaited);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (Number.isSafeInteger(parsed?.experimental_ffi_timeout))
|
|
||||||
globalThis.reflected_ffi_timeout = parsed?.experimental_ffi_timeout;
|
|
||||||
|
|
||||||
configs.set(TYPE, { config: parsed, configURL, plugins, error });
|
configs.set(TYPE, { config: parsed, configURL, plugins, error });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
/*! (c) PyScript Development Team */
|
/*! (c) PyScript Development Team */
|
||||||
|
|
||||||
import "./zero-redirect.js";
|
|
||||||
import stickyModule from "sticky-module";
|
import stickyModule from "sticky-module";
|
||||||
import withResolvers from "@webreflection/utils/with-resolvers";
|
import "@ungap/with-resolvers";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
INVALID_CONTENT,
|
INVALID_CONTENT,
|
||||||
@@ -117,7 +116,7 @@ for (const [TYPE, interpreter] of TYPES) {
|
|||||||
else dispatch(element, TYPE, "done");
|
else dispatch(element, TYPE, "done");
|
||||||
};
|
};
|
||||||
|
|
||||||
let { config, configURL, plugins, error } = configs.get(TYPE);
|
const { config, configURL, plugins, error } = configs.get(TYPE);
|
||||||
|
|
||||||
// create a unique identifier when/if needed
|
// create a unique identifier when/if needed
|
||||||
let id = 0;
|
let id = 0;
|
||||||
@@ -311,24 +310,13 @@ for (const [TYPE, interpreter] of TYPES) {
|
|||||||
|
|
||||||
hooked.set(TYPE, hooks);
|
hooked.set(TYPE, hooks);
|
||||||
|
|
||||||
// allow offline interpreter detection via [offline] attribute
|
|
||||||
let version = offline_interpreter(config);
|
|
||||||
if (!version) {
|
|
||||||
const css = "script[type='module'][offline]";
|
|
||||||
const s = document.querySelector(css)?.src;
|
|
||||||
if (s && import.meta.url.startsWith(s.replace(/\.js$/, ""))) {
|
|
||||||
version = `./pyscript/${interpreter}/${interpreter}.mjs`;
|
|
||||||
version = offline_interpreter({ interpreter: version });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
define(TYPE, {
|
define(TYPE, {
|
||||||
config,
|
config,
|
||||||
configURL,
|
configURL,
|
||||||
interpreter,
|
interpreter,
|
||||||
hooks,
|
hooks,
|
||||||
version,
|
|
||||||
env: `${TYPE}-script`,
|
env: `${TYPE}-script`,
|
||||||
|
version: offline_interpreter(config),
|
||||||
onerror(error, element) {
|
onerror(error, element) {
|
||||||
errors.set(element, error);
|
errors.set(element, error);
|
||||||
},
|
},
|
||||||
@@ -339,7 +327,7 @@ for (const [TYPE, interpreter] of TYPES) {
|
|||||||
class extends HTMLElement {
|
class extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
assign(super(), {
|
assign(super(), {
|
||||||
_wrap: withResolvers(),
|
_wrap: Promise.withResolvers(),
|
||||||
srcCode: "",
|
srcCode: "",
|
||||||
executed: false,
|
executed: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import IDBMap from "@webreflection/idb-map";
|
import IDBMap from "@webreflection/idb-map";
|
||||||
import withResolvers from "@webreflection/utils/with-resolvers";
|
|
||||||
import { assign } from "polyscript/exports";
|
import { assign } from "polyscript/exports";
|
||||||
import { $$ } from "basic-devtools";
|
import { $$ } from "basic-devtools";
|
||||||
|
|
||||||
@@ -27,7 +26,7 @@ export const getFileSystemDirectoryHandle = async (options) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { promise, resolve, reject } = withResolvers();
|
const { promise, resolve, reject } = Promise.withResolvers();
|
||||||
|
|
||||||
const how = { id: "pyscript", mode: "readwrite", ...options };
|
const how = { id: "pyscript", mode: "readwrite", ...options };
|
||||||
if (options.hint) how.startIn = options.hint;
|
if (options.hint) how.startIn = options.hint;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// PyScript py-editor plugin
|
// PyScript py-editor plugin
|
||||||
import withResolvers from "@webreflection/utils/with-resolvers";
|
|
||||||
import { Hook, XWorker, dedent, defineProperties } from "polyscript/exports";
|
import { Hook, XWorker, dedent, defineProperties } from "polyscript/exports";
|
||||||
import { TYPES, offline_interpreter, relative_url, stdlib } from "../core.js";
|
import { TYPES, offline_interpreter, relative_url, stdlib } from "../core.js";
|
||||||
import { notify } from "./error.js";
|
import { notify } from "./error.js";
|
||||||
@@ -38,7 +37,7 @@ const getRelatedScript = (target, type) => {
|
|||||||
return editor?.parentNode?.previousElementSibling;
|
return editor?.parentNode?.previousElementSibling;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function execute({ currentTarget, script }) {
|
async function execute({ currentTarget }) {
|
||||||
const { env, pySrc, outDiv } = this;
|
const { env, pySrc, outDiv } = this;
|
||||||
const hasRunButton = !!currentTarget;
|
const hasRunButton = !!currentTarget;
|
||||||
|
|
||||||
@@ -91,15 +90,16 @@ async function execute({ currentTarget, script }) {
|
|||||||
// creation and destruction of editors on the fly
|
// creation and destruction of editors on the fly
|
||||||
if (hasRunButton) {
|
if (hasRunButton) {
|
||||||
for (const type of TYPES.keys()) {
|
for (const type of TYPES.keys()) {
|
||||||
script = getRelatedScript(currentTarget, type);
|
const script = getRelatedScript(currentTarget, type);
|
||||||
if (script) break;
|
if (script) {
|
||||||
|
defineProperties(script, { xworker: { value: xworker } });
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProperties(script, { xworker: { value: xworker } });
|
|
||||||
|
|
||||||
const { sync } = xworker;
|
const { sync } = xworker;
|
||||||
const { promise, resolve } = withResolvers();
|
const { promise, resolve } = Promise.withResolvers();
|
||||||
envs.set(env, promise);
|
envs.set(env, promise);
|
||||||
sync.revoke = () => {
|
sync.revoke = () => {
|
||||||
URL.revokeObjectURL(srcLink);
|
URL.revokeObjectURL(srcLink);
|
||||||
@@ -126,14 +126,6 @@ async function execute({ currentTarget, script }) {
|
|||||||
if (hasRunButton) {
|
if (hasRunButton) {
|
||||||
currentTarget.classList.remove("running");
|
currentTarget.classList.remove("running");
|
||||||
currentTarget.innerHTML = RUN_BUTTON;
|
currentTarget.innerHTML = RUN_BUTTON;
|
||||||
const { previousElementSibling } =
|
|
||||||
currentTarget.closest("[data-env]").parentElement;
|
|
||||||
previousElementSibling?.dispatchEvent(
|
|
||||||
new Event("py-editor:done", {
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const { sync } = xworker;
|
const { sync } = xworker;
|
||||||
@@ -156,20 +148,6 @@ async function execute({ currentTarget, script }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const replaceScript = (script, type) => {
|
|
||||||
script.xworker?.terminate();
|
|
||||||
const clone = script.cloneNode(true);
|
|
||||||
clone.type = `${type}-editor`;
|
|
||||||
const editor = editors.get(script);
|
|
||||||
if (editor) {
|
|
||||||
const content = editor.state.doc.toString();
|
|
||||||
clone.textContent = content;
|
|
||||||
editors.delete(script);
|
|
||||||
script.nextElementSibling.remove();
|
|
||||||
}
|
|
||||||
script.replaceWith(clone);
|
|
||||||
};
|
|
||||||
|
|
||||||
const makeRunButton = (handler, type) => {
|
const makeRunButton = (handler, type) => {
|
||||||
const runButton = document.createElement("button");
|
const runButton = document.createElement("button");
|
||||||
runButton.className = `absolute ${type}-editor-run-button`;
|
runButton.className = `absolute ${type}-editor-run-button`;
|
||||||
@@ -182,25 +160,15 @@ const makeRunButton = (handler, type) => {
|
|||||||
) {
|
) {
|
||||||
const script = getRelatedScript(runButton, type);
|
const script = getRelatedScript(runButton, type);
|
||||||
if (script) {
|
if (script) {
|
||||||
const env = script.getAttribute("env");
|
const editor = editors.get(script);
|
||||||
// remove the bootstrapped env which could be one or shared
|
const content = editor.state.doc.toString();
|
||||||
if (env) {
|
const clone = script.cloneNode(true);
|
||||||
for (const [key, value] of TYPES) {
|
clone.type = `${type}-editor`;
|
||||||
if (key === type) {
|
clone.textContent = content;
|
||||||
configs.delete(`${value}-${env}`);
|
script.xworker.terminate();
|
||||||
envs.delete(`${value}-${env}`);
|
script.nextElementSibling.remove();
|
||||||
break;
|
script.replaceWith(clone);
|
||||||
}
|
editors.delete(script);
|
||||||
}
|
|
||||||
}
|
|
||||||
// lonley script without setup node should be replaced
|
|
||||||
if (script.xworker) replaceScript(script, type);
|
|
||||||
// all scripts sharing the same env should be replaced
|
|
||||||
else {
|
|
||||||
const sel = `script[type^="${type}-editor"][env="${env}"]`;
|
|
||||||
for (const script of document.querySelectorAll(sel))
|
|
||||||
replaceScript(script, type);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -388,7 +356,7 @@ const init = async (script, type, interpreter) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isSetup) {
|
if (isSetup) {
|
||||||
await context.handleEvent({ currentTarget: null, script });
|
await context.handleEvent({ currentTarget: null });
|
||||||
notifyEditor();
|
notifyEditor();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -426,17 +394,16 @@ const init = async (script, type, interpreter) => {
|
|||||||
// preserve user indentation, if any
|
// preserve user indentation, if any
|
||||||
const indentation = /^([ \t]+)/m.test(doc) ? RegExp.$1 : " ";
|
const indentation = /^([ \t]+)/m.test(doc) ? RegExp.$1 : " ";
|
||||||
|
|
||||||
const listener = () => !runButton.click();
|
const listener = () => runButton.click();
|
||||||
const editor = new EditorView({
|
const editor = new EditorView({
|
||||||
extensions: [
|
extensions: [
|
||||||
indentUnit.of(indentation),
|
indentUnit.of(indentation),
|
||||||
new Compartment().of(python()),
|
new Compartment().of(python()),
|
||||||
keymap.of([
|
keymap.of([
|
||||||
|
...defaultKeymap,
|
||||||
{ key: "Ctrl-Enter", run: listener, preventDefault: true },
|
{ key: "Ctrl-Enter", run: listener, preventDefault: true },
|
||||||
{ key: "Cmd-Enter", run: listener, preventDefault: true },
|
{ key: "Cmd-Enter", run: listener, preventDefault: true },
|
||||||
{ key: "Shift-Enter", run: listener, preventDefault: true },
|
{ key: "Shift-Enter", run: listener, preventDefault: true },
|
||||||
// Consider removing defaultKeymap as likely redundant with basicSetup
|
|
||||||
...defaultKeymap,
|
|
||||||
// @see https://codemirror.net/examples/tab/
|
// @see https://codemirror.net/examples/tab/
|
||||||
indentWithTab,
|
indentWithTab,
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// PyScript pyodide terminal plugin
|
// PyScript pyodide terminal plugin
|
||||||
import withResolvers from "@webreflection/utils/with-resolvers";
|
|
||||||
import { defineProperties } from "polyscript/exports";
|
import { defineProperties } from "polyscript/exports";
|
||||||
import { hooks, inputFailure } from "../../core.js";
|
import { hooks, inputFailure } from "../../core.js";
|
||||||
|
|
||||||
@@ -147,7 +146,7 @@ export default async (element) => {
|
|||||||
// frees the worker on \r
|
// frees the worker on \r
|
||||||
sync.pyterminal_read = (buffer) => {
|
sync.pyterminal_read = (buffer) => {
|
||||||
terminal.write(buffer);
|
terminal.write(buffer);
|
||||||
promisedChunks = withResolvers();
|
promisedChunks = Promise.withResolvers();
|
||||||
return promisedChunks.promise;
|
return promisedChunks.promise;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
import "polyscript/service-worker";
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,105 +1,36 @@
|
|||||||
"""
|
# Some notes about the naming conventions and the relationship between various
|
||||||
This is the main `pyscript` namespace. It provides the primary Pythonic API
|
# similar-but-different names.
|
||||||
for users to interact with the
|
#
|
||||||
[browser's own API](https://developer.mozilla.org/en-US/docs/Web/API). It
|
# import pyscript
|
||||||
includes utilities for common activities such as displaying content, handling
|
# this package contains the main user-facing API offered by pyscript. All
|
||||||
events, fetching resources, managing local storage, and coordinating with
|
# the names which are supposed be used by end users should be made
|
||||||
web workers.
|
# available in pyscript/__init__.py (i.e., this file)
|
||||||
|
#
|
||||||
The most important names provided by this namespace can be directly imported
|
# import _pyscript
|
||||||
from `pyscript`, for example:
|
# this is an internal module implemented in JS. It is used internally by
|
||||||
|
# the pyscript package, end users should not use it directly. For its
|
||||||
```python
|
# implementation, grep for `interpreter.registerJsModule("_pyscript",
|
||||||
from pyscript import display, HTML, fetch, when, storage, WebSocket
|
# ...)` in core.js
|
||||||
```
|
#
|
||||||
|
# import js
|
||||||
The following names are available in the `pyscript` namespace:
|
# this is the JS globalThis, as exported by pyodide and/or micropython's
|
||||||
|
# FFIs. As such, it contains different things in the main thread or in a
|
||||||
- `RUNNING_IN_WORKER`: Boolean indicating if the code is running in a Web
|
# worker.
|
||||||
Worker.
|
#
|
||||||
- `PyWorker`: Class for creating Web Workers running Python code.
|
# import pyscript.magic_js
|
||||||
- `config`: Configuration object for pyscript settings.
|
# this submodule abstracts away some of the differences between the main
|
||||||
- `current_target`: The element in the DOM that is the current target for
|
# thread and the worker. In particular, it defines `window` and `document`
|
||||||
output.
|
# in such a way that these names work in both cases: in the main thread,
|
||||||
- `document`: The standard `document` object, proxied in workers.
|
# they are the "real" objects, in the worker they are proxies which work
|
||||||
- `window`: The standard `window` object, proxied in workers.
|
# thanks to coincident.
|
||||||
- `js_import`: Function to dynamically import JS modules.
|
#
|
||||||
- `js_modules`: Object containing JS modules available to Python.
|
# from pyscript import window, document
|
||||||
- `sync`: Utility for synchronizing between worker and main thread.
|
# these are just the window and document objects as defined by
|
||||||
- `display`: Function to render Python objects in the web page.
|
# pyscript.magic_js. This is the blessed way to access them from pyscript,
|
||||||
- `HTML`: Helper class to create HTML content for display.
|
# as it works transparently in both the main thread and worker cases.
|
||||||
- `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.context import (
|
from pyscript.magic_js import (
|
||||||
RUNNING_IN_WORKER,
|
RUNNING_IN_WORKER,
|
||||||
PyWorker,
|
PyWorker,
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -1,198 +0,0 @@
|
|||||||
"""
|
|
||||||
Execution context management for PyScript.
|
|
||||||
|
|
||||||
This module handles the differences between running in the
|
|
||||||
[main browser thread](https://developer.mozilla.org/en-US/docs/Glossary/Main_thread)
|
|
||||||
versus running in a
|
|
||||||
[Web Worker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers),
|
|
||||||
providing a consistent API regardless of the execution context.
|
|
||||||
|
|
||||||
Key features:
|
|
||||||
|
|
||||||
- Detects whether code is running in a worker or main thread. Read this via
|
|
||||||
the boolean `pyscript.context.RUNNING_IN_WORKER`.
|
|
||||||
- Parses and normalizes configuration from `polyscript.config` and adds the
|
|
||||||
Python interpreter type via the `type` key in `pyscript.context.config`.
|
|
||||||
- Provides appropriate implementations of `window`, `document`, and `sync`.
|
|
||||||
- Sets up JavaScript module import system, including a lazy `js_import`
|
|
||||||
function.
|
|
||||||
- Manages `PyWorker` creation.
|
|
||||||
- Provides access to the current display target via
|
|
||||||
`pyscript.context.display_target`.
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
|
|
||||||
These are key differences between the main thread and worker contexts:
|
|
||||||
|
|
||||||
Main thread context:
|
|
||||||
|
|
||||||
- `window` and `document` are available directly.
|
|
||||||
- `PyWorker` can be created to spawn worker threads.
|
|
||||||
- `sync` is not available (raises `NotSupported`).
|
|
||||||
|
|
||||||
Worker context:
|
|
||||||
|
|
||||||
- `window` and `document` are proxied from main thread (if SharedArrayBuffer
|
|
||||||
available).
|
|
||||||
- `PyWorker` is not available (raises `NotSupported`).
|
|
||||||
- `sync` utilities are available for main thread communication.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import js
|
|
||||||
from polyscript import config as _polyscript_config
|
|
||||||
from polyscript import js_modules
|
|
||||||
from pyscript.util import NotSupported
|
|
||||||
|
|
||||||
RUNNING_IN_WORKER = not hasattr(js, "document")
|
|
||||||
"""Detect execution context: True if running in a worker, False if main thread."""
|
|
||||||
|
|
||||||
config = json.loads(js.JSON.stringify(_polyscript_config))
|
|
||||||
"""Parsed and normalized configuration."""
|
|
||||||
if isinstance(config, str):
|
|
||||||
config = {}
|
|
||||||
|
|
||||||
js_import = None
|
|
||||||
"""Function to import JavaScript modules dynamically."""
|
|
||||||
|
|
||||||
window = None
|
|
||||||
"""The `window` object (proxied if in a worker)."""
|
|
||||||
|
|
||||||
document = None
|
|
||||||
"""The `document` object (proxied if in a worker)."""
|
|
||||||
|
|
||||||
sync = None
|
|
||||||
"""Sync utilities for worker-main thread communication (only in workers)."""
|
|
||||||
|
|
||||||
# Detect and add Python interpreter type to config.
|
|
||||||
if "MicroPython" in sys.version:
|
|
||||||
config["type"] = "mpy"
|
|
||||||
else:
|
|
||||||
config["type"] = "py"
|
|
||||||
|
|
||||||
|
|
||||||
class _JSModuleProxy:
|
|
||||||
"""
|
|
||||||
Proxy for JavaScript modules imported via js_modules.
|
|
||||||
|
|
||||||
This allows Python code to import JavaScript modules using Python's
|
|
||||||
import syntax:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pyscript.js_modules lodash import debounce
|
|
||||||
```
|
|
||||||
|
|
||||||
The proxy lazily retrieves the actual JavaScript module when accessed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, name):
|
|
||||||
"""
|
|
||||||
Create a proxy for the named JavaScript module.
|
|
||||||
"""
|
|
||||||
self.name = name
|
|
||||||
|
|
||||||
def __getattr__(self, field):
|
|
||||||
"""
|
|
||||||
Retrieve a JavaScript object/function from the proxied JavaScript
|
|
||||||
module via the given `field` name.
|
|
||||||
"""
|
|
||||||
# Avoid Pyodide looking for non-existent special methods.
|
|
||||||
if not field.startswith("_"):
|
|
||||||
return getattr(getattr(js_modules, self.name), field)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# Register all available JavaScript modules in Python's module system.
|
|
||||||
# This enables: from pyscript.js_modules.xxx import yyy
|
|
||||||
for module_name in js.Reflect.ownKeys(js_modules):
|
|
||||||
sys.modules[f"pyscript.js_modules.{module_name}"] = _JSModuleProxy(module_name)
|
|
||||||
sys.modules["pyscript.js_modules"] = js_modules
|
|
||||||
|
|
||||||
|
|
||||||
# Context-specific setup: Worker vs Main Thread.
|
|
||||||
if RUNNING_IN_WORKER:
|
|
||||||
import polyscript
|
|
||||||
|
|
||||||
# PyWorker cannot be created from within a worker.
|
|
||||||
PyWorker = NotSupported(
|
|
||||||
"pyscript.PyWorker",
|
|
||||||
"pyscript.PyWorker works only when running in the main thread",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Attempt to access main thread's window and document via SharedArrayBuffer.
|
|
||||||
try:
|
|
||||||
window = polyscript.xworker.window
|
|
||||||
document = window.document
|
|
||||||
js.document = document
|
|
||||||
|
|
||||||
# Create js_import function that runs imports on the main thread.
|
|
||||||
js_import = window.Function(
|
|
||||||
"return (...urls) => Promise.all(urls.map((url) => import(url)))"
|
|
||||||
)()
|
|
||||||
|
|
||||||
except:
|
|
||||||
# SharedArrayBuffer not available - window/document cannot be proxied.
|
|
||||||
sab_error_message = (
|
|
||||||
"Unable to use `window` or `document` in worker. "
|
|
||||||
"This requires SharedArrayBuffer support. "
|
|
||||||
"See: https://docs.pyscript.net/latest/faq/#sharedarraybuffer"
|
|
||||||
)
|
|
||||||
js.console.warn(sab_error_message)
|
|
||||||
window = NotSupported("pyscript.window", sab_error_message)
|
|
||||||
document = NotSupported("pyscript.document", sab_error_message)
|
|
||||||
|
|
||||||
# Worker-specific utilities for main thread communication.
|
|
||||||
sync = polyscript.xworker.sync
|
|
||||||
|
|
||||||
def current_target():
|
|
||||||
"""
|
|
||||||
Get the current output target in worker context.
|
|
||||||
"""
|
|
||||||
return polyscript.target
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Main thread context setup.
|
|
||||||
import _pyscript
|
|
||||||
from _pyscript import PyWorker as _PyWorker
|
|
||||||
from pyscript.ffi import to_js
|
|
||||||
|
|
||||||
js_import = _pyscript.js_import
|
|
||||||
|
|
||||||
def PyWorker(url, **options):
|
|
||||||
"""
|
|
||||||
Create a Web Worker running Python code.
|
|
||||||
|
|
||||||
This spawns a new worker thread that can execute Python code
|
|
||||||
found at the `url`, independently of the main thread. The
|
|
||||||
`**options` can be used to configure the worker.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pyscript import PyWorker
|
|
||||||
|
|
||||||
|
|
||||||
# Create a worker to run background tasks.
|
|
||||||
# (`type` MUST be either `micropython` or `pyodide`)
|
|
||||||
worker = PyWorker("./worker.py", type="micropython")
|
|
||||||
```
|
|
||||||
|
|
||||||
PyWorker **can only be created from the main thread**, not from
|
|
||||||
within another worker.
|
|
||||||
"""
|
|
||||||
return _PyWorker(url, to_js(options))
|
|
||||||
|
|
||||||
# Main thread has direct access to window and document.
|
|
||||||
window = js
|
|
||||||
document = js.document
|
|
||||||
|
|
||||||
# sync is not available in main thread (only in workers).
|
|
||||||
sync = NotSupported(
|
|
||||||
"pyscript.sync", "pyscript.sync works only when running in a worker"
|
|
||||||
)
|
|
||||||
|
|
||||||
def current_target():
|
|
||||||
"""
|
|
||||||
Get the current output target in main thread context.
|
|
||||||
"""
|
|
||||||
return _pyscript.target
|
|
||||||
@@ -1,104 +1,63 @@
|
|||||||
"""
|
|
||||||
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
|
||||||
from collections import OrderedDict
|
import re
|
||||||
from pyscript.context import current_target, document, window
|
|
||||||
from pyscript.ffi import is_none
|
|
||||||
|
|
||||||
|
from pyscript.magic_js import current_target, document, window
|
||||||
|
|
||||||
def _render_image(mime, value, meta):
|
_MIME_METHODS = {
|
||||||
"""
|
"savefig": "image/png",
|
||||||
Render image (`mime`) data (`value`) as an HTML img element with data URL.
|
"_repr_javascript_": "application/javascript",
|
||||||
Any `meta` attributes are added to the img tag.
|
"_repr_json_": "application/json",
|
||||||
|
"_repr_latex": "text/latex",
|
||||||
Accepts both raw bytes and base64-encoded strings for flexibility. This
|
"_repr_png_": "image/png",
|
||||||
only handles PNG and JPEG images. SVG images are handled separately as
|
"_repr_jpeg_": "image/jpeg",
|
||||||
their raw XML content (which the browser can render directly).
|
"_repr_pdf_": "application/pdf",
|
||||||
"""
|
"_repr_svg_": "image/svg+xml",
|
||||||
if isinstance(value, bytes):
|
"_repr_markdown_": "text/markdown",
|
||||||
value = base64.b64encode(value).decode("utf-8")
|
"_repr_html_": "text/html",
|
||||||
attrs = "".join([f' {k}="{v}"' for k, v in meta.items()])
|
"__repr__": "text/plain",
|
||||||
return f'<img src="data:{mime};base64,{value}"{attrs}>'
|
|
||||||
|
|
||||||
|
|
||||||
# Maps MIME types to rendering functions.
|
|
||||||
_MIME_TO_RENDERERS = {
|
|
||||||
"text/plain": lambda v, m: html.escape(v),
|
|
||||||
"text/html": lambda v, m: v,
|
|
||||||
"image/png": lambda v, m: _render_image("image/png", v, m),
|
|
||||||
"image/jpeg": lambda v, m: _render_image("image/jpeg", v, m),
|
|
||||||
"image/svg+xml": lambda v, m: v,
|
|
||||||
"application/json": lambda v, m: v,
|
|
||||||
"application/javascript": lambda v, m: f"<script>{v}<\\/script>",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Maps Python representation methods to MIME types. This is an ordered dict
|
def _render_image(mime, value, meta):
|
||||||
# because the order defines preference when multiple methods are available,
|
# If the image value is using bytes we should convert it to base64
|
||||||
# and MicroPython's limited dicts don't preserve insertion order.
|
# otherwise it will return raw bytes and the browser will not be able to
|
||||||
_METHOD_TO_MIME = OrderedDict(
|
# render it.
|
||||||
[
|
if isinstance(value, bytes):
|
||||||
("savefig", "image/png"),
|
value = base64.b64encode(value).decode("utf-8")
|
||||||
("_repr_png_", "image/png"),
|
|
||||||
("_repr_jpeg_", "image/jpeg"),
|
# This is the pattern of base64 strings
|
||||||
("_repr_svg_", "image/svg+xml"),
|
base64_pattern = re.compile(
|
||||||
("_repr_html_", "text/html"),
|
r"^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$"
|
||||||
("_repr_json_", "application/json"),
|
)
|
||||||
("_repr_javascript_", "application/javascript"),
|
# If value doesn't match the base64 pattern we should encode it to base64
|
||||||
("__repr__", "text/plain"),
|
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):
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
_MIME_RENDERERS = {
|
||||||
|
"text/plain": html.escape,
|
||||||
|
"text/html": _identity,
|
||||||
|
"image/png": lambda value, meta: _render_image("image/png", value, meta),
|
||||||
|
"image/jpeg": lambda value, meta: _render_image("image/jpeg", value, meta),
|
||||||
|
"image/svg+xml": _identity,
|
||||||
|
"application/json": _identity,
|
||||||
|
"application/javascript": lambda value, meta: f"<script>{value}<\\/script>",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class HTML:
|
class HTML:
|
||||||
"""
|
"""
|
||||||
Wrap a string to render as unescaped HTML in `display()`. This is
|
Wrap a string so that display() can render it as plain HTML
|
||||||
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):
|
||||||
@@ -108,156 +67,112 @@ class HTML:
|
|||||||
return self._html
|
return self._html
|
||||||
|
|
||||||
|
|
||||||
def _get_representation(obj, method):
|
def _eval_formatter(obj, print_method):
|
||||||
"""
|
"""
|
||||||
Call the given representation `method` on an object (`obj`).
|
Evaluates a formatter method.
|
||||||
|
|
||||||
Handles special cases like matplotlib's `savefig`. Returns `None`
|
|
||||||
if the `method` doesn't exist.
|
|
||||||
"""
|
"""
|
||||||
if method == "__repr__":
|
if print_method == "__repr__":
|
||||||
return repr(obj)
|
return repr(obj)
|
||||||
if not hasattr(obj, method):
|
if hasattr(obj, print_method):
|
||||||
return None
|
if print_method == "savefig":
|
||||||
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 _get_content_and_mime(obj):
|
def _format_mime(obj):
|
||||||
"""
|
"""
|
||||||
Returns the formatted raw content to be inserted into the DOM representing
|
Formats object using _repr_x_ methods.
|
||||||
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 = _get_representation(obj, "_repr_mimebundle_")
|
mimebundle = _eval_formatter(obj, "_repr_mimebundle_")
|
||||||
if mimebundle:
|
if isinstance(mimebundle, tuple):
|
||||||
if isinstance(mimebundle, tuple):
|
format_dict, _ = mimebundle
|
||||||
# Grab global metadata.
|
else:
|
||||||
format_dict, global_meta = mimebundle
|
format_dict = mimebundle
|
||||||
|
|
||||||
|
output, not_available = None, []
|
||||||
|
for method, mime_type in _MIME_METHODS.items():
|
||||||
|
if mime_type in format_dict:
|
||||||
|
output = format_dict[mime_type]
|
||||||
else:
|
else:
|
||||||
format_dict, global_meta = mimebundle, {}
|
output = _eval_formatter(obj, method)
|
||||||
# Try to render using mimebundle formats.
|
|
||||||
for mime_type, output in format_dict.items():
|
|
||||||
if mime_type in _MIME_TO_RENDERERS:
|
|
||||||
meta = global_meta.get(mime_type, {})
|
|
||||||
# If output is a tuple, merge format-specific metadata.
|
|
||||||
if isinstance(output, tuple):
|
|
||||||
output, format_meta = output
|
|
||||||
meta.update(format_meta)
|
|
||||||
return _MIME_TO_RENDERERS[mime_type](output, meta), mime_type
|
|
||||||
# No mimebundle or no available renderers therein, so try individual
|
|
||||||
# methods.
|
|
||||||
for method, mime_type in _METHOD_TO_MIME.items():
|
|
||||||
if mime_type not in _MIME_TO_RENDERERS:
|
|
||||||
continue
|
|
||||||
output = _get_representation(obj, method)
|
|
||||||
if output is None:
|
if output is None:
|
||||||
continue
|
continue
|
||||||
|
if mime_type not in _MIME_RENDERERS:
|
||||||
|
not_available.append(mime_type)
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
if output is None:
|
||||||
|
if not_available:
|
||||||
|
window.console.warn(
|
||||||
|
f"Rendered object requested unavailable MIME renderers: {not_available}"
|
||||||
|
)
|
||||||
|
output = repr(output)
|
||||||
|
mime_type = "text/plain"
|
||||||
|
elif isinstance(output, tuple):
|
||||||
|
output, meta = output
|
||||||
|
else:
|
||||||
meta = {}
|
meta = {}
|
||||||
if isinstance(output, tuple):
|
return _MIME_RENDERERS[mime_type](output, meta), mime_type
|
||||||
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_to_dom(element, value, append):
|
def _write(element, value, append=False):
|
||||||
"""
|
html, mime_type = _format_mime(value)
|
||||||
Given an `element` and a `value`, write formatted content to the referenced
|
if html == "\\n":
|
||||||
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:
|
||||||
container = document.createElement("div")
|
out_element = document.createElement("div")
|
||||||
element.append(container)
|
element.append(out_element)
|
||||||
else:
|
else:
|
||||||
container = element
|
out_element = element.lastElementChild
|
||||||
|
if out_element is None:
|
||||||
|
out_element = element
|
||||||
|
|
||||||
if mime_type in ("application/javascript", "text/html"):
|
if mime_type in ("application/javascript", "text/html"):
|
||||||
container.append(document.createRange().createContextualFragment(html_content))
|
script_element = document.createRange().createContextualFragment(html)
|
||||||
|
out_element.append(script_element)
|
||||||
else:
|
else:
|
||||||
container.innerHTML = html_content
|
out_element.innerHTML = html
|
||||||
|
|
||||||
|
|
||||||
def display(*values, target=None, append=True):
|
def display(*values, target=None, append=True):
|
||||||
"""
|
if target is None:
|
||||||
Display Python objects in the web page.
|
|
||||||
|
|
||||||
* `*values`: Python objects to display. Each object is introspected to
|
|
||||||
determine the appropriate rendering method.
|
|
||||||
* `target`: DOM element ID where content should be displayed. If `None`
|
|
||||||
(default), uses the current script tag's designated output area. This
|
|
||||||
can start with '#' (which will be stripped for compatibility).
|
|
||||||
* `append`: If `True` (default), add content to existing output. If
|
|
||||||
`False`, replace existing content before displaying.
|
|
||||||
|
|
||||||
When used in a worker, `display()` requires an explicit `target` parameter
|
|
||||||
to identify where content will be displayed. If used on the main thread,
|
|
||||||
it automatically uses the current `<script>` tag as the target. If the
|
|
||||||
script tag has a `target` attribute, that element will be used instead.
|
|
||||||
|
|
||||||
A ValueError is raised if a valid target cannot be found for the current
|
|
||||||
context.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pyscript import display, HTML
|
|
||||||
|
|
||||||
|
|
||||||
# Display raw HTML.
|
|
||||||
display(HTML("<h1>Hello, World!</h1>"))
|
|
||||||
|
|
||||||
# Display in current script's output area.
|
|
||||||
display("Hello, World!")
|
|
||||||
|
|
||||||
# Display in a specific element.
|
|
||||||
display("Hello", target="my-div")
|
|
||||||
|
|
||||||
# Replace existing content (note the `#`).
|
|
||||||
display("New content", target="#my-div", append=False)
|
|
||||||
|
|
||||||
# Display multiple values in the default target.
|
|
||||||
display("First", "Second", "Third")
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
if isinstance(target, str):
|
|
||||||
# There's a valid target.
|
|
||||||
target = target[1:] if target.startswith("#") else target
|
|
||||||
elif is_none(target):
|
|
||||||
target = current_target()
|
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 is_none(element):
|
|
||||||
raise ValueError(f"Cannot find element with id='{target}' in the page.")
|
# If target cannot be found on the page, a ValueError is raised
|
||||||
# If possible, use a script tag's target attribute.
|
if element is None:
|
||||||
|
msg = f"Invalid selector with id={target}. Cannot be found in the page."
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
# if element is a <script type="py">, it has a 'target' attribute which
|
||||||
|
# points to the visual element holding the displayed values. In that case,
|
||||||
|
# use that.
|
||||||
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.
|
|
||||||
if not append:
|
for v in values:
|
||||||
element.replaceChildren()
|
if not append:
|
||||||
# Add each value.
|
element.replaceChildren()
|
||||||
for value in values:
|
_write(element, v, append=append)
|
||||||
_write_to_dom(element, value, append)
|
|
||||||
|
|||||||
@@ -1,47 +1,17 @@
|
|||||||
"""
|
|
||||||
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.context import document
|
from pyscript.magic_js import document
|
||||||
from pyscript.ffi import create_proxy, to_js
|
from pyscript.ffi import create_proxy
|
||||||
from pyscript.util import is_awaitable
|
from pyscript.util import is_awaitable
|
||||||
|
from pyscript import config
|
||||||
|
|
||||||
|
|
||||||
class Event:
|
class Event:
|
||||||
"""
|
"""
|
||||||
A custom event that can notify multiple listeners when triggered.
|
Represents something that may happen at some point in the future.
|
||||||
|
|
||||||
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):
|
||||||
@@ -49,189 +19,150 @@ class Event:
|
|||||||
|
|
||||||
def trigger(self, result):
|
def trigger(self, result):
|
||||||
"""
|
"""
|
||||||
Trigger the event and notify all listeners with the given `result`.
|
Trigger the event with a result to pass into the handlers.
|
||||||
"""
|
"""
|
||||||
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 function to be called when this event is triggered.
|
Add a callable/awaitable to listen to 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 not callable(listener):
|
if is_awaitable(listener) or callable(listener):
|
||||||
msg = "Listener must be callable."
|
if listener not in self._listeners:
|
||||||
|
self._listeners.append(listener)
|
||||||
|
else:
|
||||||
|
msg = "Listener must be callable or awaitable."
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
if listener not in self._listeners:
|
|
||||||
self._listeners.append(listener)
|
|
||||||
|
|
||||||
def remove_listener(self, *listeners):
|
def remove_listener(self, *args):
|
||||||
"""
|
"""
|
||||||
Remove specified `listeners`. If none specified, remove all listeners.
|
Clear the specified handler functions in *args. If no handlers
|
||||||
|
provided, clear all handlers.
|
||||||
"""
|
"""
|
||||||
if listeners:
|
if args:
|
||||||
for listener in listeners:
|
for listener in args:
|
||||||
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(event_type, selector=None, **options):
|
def when(target, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
A decorator to handle DOM events or custom `Event` objects.
|
Add an event listener to the target element(s) for the specified event type.
|
||||||
|
|
||||||
For DOM events, specify the `event_type` (e.g. `"click"`) and a `selector`
|
The target can be a string representing the event type, or an Event object.
|
||||||
for target elements. For custom `Event` objects, just pass the `Event`
|
If the target is an Event object, the event listener will be added to that
|
||||||
instance as the `event_type`. It's also possible to pass a list of `Event`
|
object. If the target is a string, the event listener will be added to the
|
||||||
objects. The `selector` is required only for DOM events. It should be a
|
element(s) that match the (second) selector argument.
|
||||||
[CSS selector string](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Selectors),
|
|
||||||
`Element`, `ElementCollection`, or list of DOM elements.
|
|
||||||
|
|
||||||
For DOM events only, you can specify optional
|
If a (third) handler argument is provided, it will be called when the event
|
||||||
[addEventListener options](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#options):
|
is triggered; thus allowing this to be used as both a function and a
|
||||||
`capture`, `once`, `passive`, or `signal`.
|
decorator.
|
||||||
|
|
||||||
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 isinstance(event_type, str):
|
# If "when" is called as a function, try to grab the handler from the
|
||||||
# This is a DOM event to handle, so check and use the selector.
|
# arguments. If there's no handler, this must be a decorator based call.
|
||||||
|
handler = None
|
||||||
|
if args and (callable(args[0]) or is_awaitable(args[0])):
|
||||||
|
handler = args[0]
|
||||||
|
elif callable(kwargs.get("handler")) or is_awaitable(kwargs.get("handler")):
|
||||||
|
handler = kwargs.pop("handler")
|
||||||
|
# If the target is a string, it is the "older" use of `when` where it
|
||||||
|
# represents the name of a DOM event.
|
||||||
|
if isinstance(target, str):
|
||||||
|
# Extract the selector from the arguments or keyword arguments.
|
||||||
|
selector = args[0] if args else kwargs.pop("selector")
|
||||||
if not selector:
|
if not selector:
|
||||||
raise ValueError("Selector required for DOM event handling.")
|
msg = "No selector provided."
|
||||||
elements = _get_elements(selector)
|
raise ValueError(msg)
|
||||||
if not elements:
|
# Grab the DOM elements to which the target event will be attached.
|
||||||
raise ValueError(f"No elements found for selector: {selector}")
|
from pyscript.web import Element, ElementCollection
|
||||||
|
|
||||||
|
if isinstance(selector, str):
|
||||||
|
elements = document.querySelectorAll(selector)
|
||||||
|
elif isinstance(selector, Element):
|
||||||
|
elements = [selector._dom_element]
|
||||||
|
elif isinstance(selector, ElementCollection):
|
||||||
|
elements = [el._dom_element for el in selector]
|
||||||
|
else:
|
||||||
|
elements = selector if isinstance(selector, list) else [selector]
|
||||||
|
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
wrapper = _create_wrapper(func)
|
if config["type"] == "mpy": # Is MicroPython?
|
||||||
if isinstance(event_type, Event):
|
if is_awaitable(func):
|
||||||
# Custom Event - add listener.
|
|
||||||
event_type.add_listener(wrapper)
|
async def wrapper(*args, **kwargs):
|
||||||
elif isinstance(event_type, list) and all(
|
"""
|
||||||
isinstance(t, Event) for t in event_type
|
This is a very ugly hack to get micropython working because
|
||||||
):
|
`inspect.signature` doesn't exist. It may be actually better
|
||||||
# List of custom Events - add listener to each.
|
to not try any magic for now and raise the error.
|
||||||
for event in event_type:
|
"""
|
||||||
event.add_listener(wrapper)
|
try:
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
except TypeError as e:
|
||||||
|
if "takes" in str(e) and "positional arguments" in str(e):
|
||||||
|
return await func()
|
||||||
|
raise
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
This is a very ugly hack to get micropython working because
|
||||||
|
`inspect.signature` doesn't exist. It may be actually better
|
||||||
|
to not try any magic for now and raise the error.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
except TypeError as e:
|
||||||
|
if "takes" in str(e) and "positional arguments" in str(e):
|
||||||
|
return func()
|
||||||
|
raise
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# DOM event - attach to all matched elements.
|
sig = inspect.signature(func)
|
||||||
for element in elements:
|
if sig.parameters:
|
||||||
element.addEventListener(
|
if is_awaitable(func):
|
||||||
event_type,
|
|
||||||
create_proxy(wrapper),
|
async def wrapper(event):
|
||||||
to_js(options) if options else False,
|
return await func(event)
|
||||||
)
|
|
||||||
|
else:
|
||||||
|
wrapper = func
|
||||||
|
else:
|
||||||
|
# Function doesn't receive events.
|
||||||
|
if is_awaitable(func):
|
||||||
|
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
return await func()
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
return func()
|
||||||
|
|
||||||
|
wrapper = wraps(func)(wrapper)
|
||||||
|
if isinstance(target, Event):
|
||||||
|
# The target is a single Event object.
|
||||||
|
target.add_listener(wrapper)
|
||||||
|
elif isinstance(target, list) and all(isinstance(t, Event) for t in target):
|
||||||
|
# The target is a list of Event objects.
|
||||||
|
for evt in target:
|
||||||
|
evt.add_listener(wrapper)
|
||||||
|
else:
|
||||||
|
# The target is a string representing an event type, and so a
|
||||||
|
# DOM element or collection of elements is found in "elements".
|
||||||
|
for el in elements:
|
||||||
|
el.addEventListener(target, create_proxy(wrapper))
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
return decorator
|
# If "when" was called as a decorator, return the decorator function,
|
||||||
|
# otherwise just call the internal decorator function with the supplied
|
||||||
|
# handler.
|
||||||
def _get_elements(selector):
|
return decorator(handler) if handler else decorator
|
||||||
"""
|
|
||||||
Convert various `selector` types into a list of DOM elements.
|
|
||||||
"""
|
|
||||||
from pyscript.web import Element, ElementCollection
|
|
||||||
|
|
||||||
if isinstance(selector, str):
|
|
||||||
return list(document.querySelectorAll(selector))
|
|
||||||
elif isinstance(selector, Element):
|
|
||||||
return [selector._dom_element]
|
|
||||||
elif isinstance(selector, ElementCollection):
|
|
||||||
return [el._dom_element for el in selector]
|
|
||||||
elif isinstance(selector, list):
|
|
||||||
return selector
|
|
||||||
else:
|
|
||||||
return [selector]
|
|
||||||
|
|
||||||
|
|
||||||
def _create_wrapper(func):
|
|
||||||
"""
|
|
||||||
Create an appropriate wrapper for the given function, `func`.
|
|
||||||
|
|
||||||
The wrapper handles both sync and async functions, and respects whether
|
|
||||||
the function expects to receive event arguments.
|
|
||||||
"""
|
|
||||||
# Get the original function if it's been wrapped. This avoids wrapper
|
|
||||||
# loops when stacking decorators.
|
|
||||||
original_func = func
|
|
||||||
while hasattr(original_func, "__wrapped__"):
|
|
||||||
original_func = original_func.__wrapped__
|
|
||||||
# Inspect the original function signature.
|
|
||||||
sig = inspect.signature(original_func)
|
|
||||||
accepts_args = bool(sig.parameters)
|
|
||||||
if is_awaitable(func):
|
|
||||||
if accepts_args:
|
|
||||||
|
|
||||||
async def wrapper(event):
|
|
||||||
return await func(event)
|
|
||||||
|
|
||||||
else:
|
|
||||||
|
|
||||||
async def wrapper(*args, **kwargs):
|
|
||||||
return await func()
|
|
||||||
|
|
||||||
else:
|
|
||||||
if accepts_args:
|
|
||||||
# Always create a new wrapper function to avoid issues with
|
|
||||||
# stacked decorators getting into an infinite loop.
|
|
||||||
|
|
||||||
def wrapper(event):
|
|
||||||
return func(event)
|
|
||||||
|
|
||||||
else:
|
|
||||||
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
return func()
|
|
||||||
|
|
||||||
return wraps(func)(wrapper)
|
|
||||||
|
|||||||
@@ -1,218 +1,87 @@
|
|||||||
"""
|
|
||||||
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 json
|
||||||
|
|
||||||
import js
|
import js
|
||||||
from pyscript.util import as_bytearray
|
from pyscript.util import as_bytearray
|
||||||
|
|
||||||
|
|
||||||
class _FetchResponse:
|
### wrap the response to grant Pythonic results
|
||||||
"""
|
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()
|
||||||
# MicroPython conversion.
|
# shims in MicroPython
|
||||||
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()
|
||||||
|
|
||||||
|
|
||||||
class _FetchPromise:
|
### allow direct await to _Response methods
|
||||||
"""
|
class _DirectResponse:
|
||||||
Wraps the fetch promise to enable direct method chaining.
|
@staticmethod
|
||||||
|
def setup(promise, response):
|
||||||
This allows calling response methods directly on the fetch promise:
|
promise._response = _Response(response)
|
||||||
`await fetch(url).json()` instead of requiring two separate awaits.
|
return promise._response
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@staticmethod
|
async def _response(self):
|
||||||
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._get_response()
|
response = await self._response()
|
||||||
return await response.arrayBuffer()
|
return await response.arrayBuffer()
|
||||||
|
|
||||||
async def blob(self):
|
async def blob(self):
|
||||||
response = await self._get_response()
|
response = await self._response()
|
||||||
return await response.blob()
|
return await response.blob()
|
||||||
|
|
||||||
async def bytearray(self):
|
async def bytearray(self):
|
||||||
response = await self._get_response()
|
response = await self._response()
|
||||||
return await response.bytearray()
|
return await response.bytearray()
|
||||||
|
|
||||||
async def json(self):
|
async def json(self):
|
||||||
response = await self._get_response()
|
response = await self._response()
|
||||||
return await response.json()
|
return await response.json()
|
||||||
|
|
||||||
async def text(self):
|
async def text(self):
|
||||||
response = await self._get_response()
|
response = await self._response()
|
||||||
return await response.text()
|
return await response.text()
|
||||||
|
|
||||||
|
|
||||||
def fetch(url, **options):
|
def fetch(url, **kw):
|
||||||
"""
|
# workaround Pyodide / MicroPython dict <-> js conversion
|
||||||
Fetch a resource from the network using a Pythonic interface.
|
options = js.JSON.parse(json.dumps(kw))
|
||||||
|
awaited = lambda response, *args: _DirectResponse.setup(promise, response)
|
||||||
This wraps JavaScript's fetch API, returning Python-native data types
|
promise = js.fetch(url, options).then(awaited)
|
||||||
and supporting both direct promise awaiting and method chaining.
|
_DirectResponse(promise)
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,164 +1,18 @@
|
|||||||
"""
|
|
||||||
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_entries = js.Object.fromEntries
|
from_entries = js.Object.fromEntries
|
||||||
|
|
||||||
def _to_js_wrapper(value, **kw):
|
def _tjs(value, **kw):
|
||||||
if "dict_converter" not in kw:
|
if not hasattr(kw, "dict_converter"):
|
||||||
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 _to_js_wrapper
|
from jsffi import to_js as _tjs
|
||||||
import js
|
|
||||||
|
|
||||||
jsnull = js.Object.getPrototypeOf(js.Object.prototype)
|
create_proxy = _cp
|
||||||
|
to_js = _tjs
|
||||||
|
|
||||||
def create_proxy(func):
|
|
||||||
"""
|
|
||||||
Create a persistent JavaScript proxy of a Python function.
|
|
||||||
|
|
||||||
This proxy allows JavaScript code to call the Python function
|
|
||||||
seamlessly, maintaining the correct context and argument handling.
|
|
||||||
|
|
||||||
This is especially useful when passing Python functions as callbacks
|
|
||||||
to JavaScript APIs (without `create_proxy`, the function would be
|
|
||||||
garbage collected after the declaration of the callback).
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pyscript import ffi
|
|
||||||
from pyscript import document
|
|
||||||
|
|
||||||
my_button = document.getElementById("my-button")
|
|
||||||
|
|
||||||
def py_callback(x):
|
|
||||||
print(f"Callback called with {x}")
|
|
||||||
|
|
||||||
my_button.addEventListener("click", ffi.create_proxy(py_callback))
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
return _cp(func)
|
|
||||||
|
|
||||||
|
|
||||||
def to_js(value, **kw):
|
|
||||||
"""
|
|
||||||
Convert Python objects to JavaScript objects.
|
|
||||||
|
|
||||||
This ensures a Python `dict` becomes a
|
|
||||||
[proper JavaScript object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object)
|
|
||||||
rather a JavaScript [`Map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map),
|
|
||||||
which is more intuitive for most use cases.
|
|
||||||
|
|
||||||
Where required, the underlying `to_js` uses `Object.fromEntries` for
|
|
||||||
`dict` conversion.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pyscript import ffi
|
|
||||||
import js
|
|
||||||
|
|
||||||
|
|
||||||
note = {
|
|
||||||
"body": "This is a notification",
|
|
||||||
"icon": "icon.png"
|
|
||||||
}
|
|
||||||
|
|
||||||
js.Notification.new("Hello!", ffi.to_js(note))
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
return _to_js_wrapper(value, **kw)
|
|
||||||
|
|
||||||
|
|
||||||
def is_none(value):
|
|
||||||
"""
|
|
||||||
Check if a value is `None` or JavaScript `null`.
|
|
||||||
|
|
||||||
In Pyodide, JavaScript `null` is represented by the `jsnull` object,
|
|
||||||
so we check for both Python `None` and `jsnull`. This function ensures
|
|
||||||
consistent behavior across Pyodide and MicroPython for null-like
|
|
||||||
values.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pyscript import ffi
|
|
||||||
import js
|
|
||||||
|
|
||||||
|
|
||||||
val1 = None
|
|
||||||
val2 = js.null
|
|
||||||
val3 = 42
|
|
||||||
|
|
||||||
print(ffi.is_none(val1)) # True
|
|
||||||
print(ffi.is_none(val2)) # True
|
|
||||||
print(ffi.is_none(val3)) # False
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
return value is None or value is jsnull
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Worker context utilities from reflected-ffi.
|
|
||||||
# See https://github.com/WebReflection/reflected-ffi for more details.
|
|
||||||
from polyscript import ffi as _ffi
|
|
||||||
|
|
||||||
_assign = _ffi.assign
|
|
||||||
|
|
||||||
direct = _ffi.direct
|
|
||||||
gather = _ffi.gather
|
|
||||||
query = _ffi.query
|
|
||||||
|
|
||||||
except:
|
|
||||||
# Fallback implementations for main thread context.
|
|
||||||
import js
|
|
||||||
|
|
||||||
_assign = js.Object.assign
|
|
||||||
|
|
||||||
direct = lambda source: source
|
|
||||||
|
|
||||||
|
|
||||||
def assign(source, *args):
|
|
||||||
"""
|
|
||||||
Merge JavaScript objects (like
|
|
||||||
[Object.assign](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign)).
|
|
||||||
|
|
||||||
Takes a target object and merges properties from one or more source
|
|
||||||
objects into it, returning the modified target.
|
|
||||||
|
|
||||||
```python
|
|
||||||
obj = js.Object.new()
|
|
||||||
ffi.assign(obj, {"a": 1}, {"b": 2})
|
|
||||||
# obj now has properties a=1 and b=2
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
for arg in args:
|
|
||||||
_assign(source, to_js(arg))
|
|
||||||
return source
|
|
||||||
|
|||||||
@@ -1,36 +1,4 @@
|
|||||||
"""
|
# 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
|
||||||
|
|
||||||
@@ -146,26 +114,6 @@ 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:
|
||||||
@@ -190,31 +138,6 @@ 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 = []
|
||||||
|
|||||||
@@ -1,258 +1,60 @@
|
|||||||
"""
|
|
||||||
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 _check_permission(details):
|
|
||||||
"""
|
|
||||||
Check if permission has been granted for a filesystem handler. Returns
|
|
||||||
the handler if permission is granted, otherwise None.
|
|
||||||
"""
|
|
||||||
handler = details.handler
|
|
||||||
options = details.options
|
|
||||||
permission = await handler.queryPermission(options)
|
|
||||||
return handler if permission == "granted" else None
|
|
||||||
|
|
||||||
|
|
||||||
async def mount(path, mode="readwrite", root="", id="pyscript"):
|
async def mount(path, mode="readwrite", root="", id="pyscript"):
|
||||||
"""
|
import js
|
||||||
Mount a directory from the local filesystem to the virtual filesystem
|
from _pyscript import fs, interpreter
|
||||||
at the specified `path` mount point. The `mode` can be "readwrite" or
|
from pyscript.ffi import to_js
|
||||||
"read" to specify access level. The `root` parameter provides a hint
|
from pyscript.magic_js import (
|
||||||
for the file picker starting location. The `id` parameter allows multiple
|
RUNNING_IN_WORKER,
|
||||||
distinct mounts at the same path.
|
sync,
|
||||||
|
)
|
||||||
|
|
||||||
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
|
|
||||||
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:
|
||||||
fs_handler = sync_with_worker.storeFSHandler(mount_key, to_js(options))
|
fsh = sync.storeFSHandler(uid, to_js(options))
|
||||||
|
|
||||||
# Handle both async and SharedArrayBuffer use cases.
|
# allow both async and/or SharedArrayBuffer use case
|
||||||
if isinstance(fs_handler, bool):
|
if isinstance(fsh, bool):
|
||||||
success = fs_handler
|
success = fsh
|
||||||
else:
|
else:
|
||||||
success = await fs_handler
|
success = await fsh
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
idbm = IDBMap.new(_fs.NAMESPACE)
|
from polyscript import IDBMap
|
||||||
details = await idbm.get(mount_key)
|
|
||||||
handler = await _check_permission(details)
|
idb = IDBMap.new(fs.NAMESPACE)
|
||||||
if handler is None:
|
handler = await idb.get(uid)
|
||||||
# Force await in either async or sync scenario.
|
|
||||||
await js.Promise.resolve(sync_with_worker.getFSHandler(details.options))
|
|
||||||
handler = details.handler
|
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(_fs.ERROR)
|
raise RuntimeError(fs.ERROR)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
success = await _fs.idb.has(mount_key)
|
success = await fs.idb.has(uid)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
details = await _fs.idb.get(mount_key)
|
handler = await fs.idb.get(uid)
|
||||||
handler = await _check_permission(details)
|
|
||||||
if handler is None:
|
|
||||||
handler = await _fs.getFileSystemDirectoryHandle(details.options)
|
|
||||||
else:
|
else:
|
||||||
js_options = to_js(options)
|
handler = await fs.getFileSystemDirectoryHandle(to_js(options))
|
||||||
handler = await _fs.getFileSystemDirectoryHandle(js_options)
|
await fs.idb.set(uid, handler)
|
||||||
details = {"handler": handler, "options": js_options}
|
|
||||||
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 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
|
|
||||||
|
|||||||
80
core/src/stdlib/pyscript/magic_js.py
Normal file
80
core/src/stdlib/pyscript/magic_js.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import js as globalThis
|
||||||
|
from polyscript import config as _config
|
||||||
|
from polyscript import js_modules
|
||||||
|
from pyscript.util import NotSupported
|
||||||
|
|
||||||
|
RUNNING_IN_WORKER = not hasattr(globalThis, "document")
|
||||||
|
|
||||||
|
config = json.loads(globalThis.JSON.stringify(_config))
|
||||||
|
|
||||||
|
if "MicroPython" in sys.version:
|
||||||
|
config["type"] = "mpy"
|
||||||
|
else:
|
||||||
|
config["type"] = "py"
|
||||||
|
|
||||||
|
|
||||||
|
# allow `from pyscript.js_modules.xxx import yyy`
|
||||||
|
class JSModule:
|
||||||
|
def __init__(self, name):
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
def __getattr__(self, field):
|
||||||
|
# avoid pyodide looking for non existent fields
|
||||||
|
if not field.startswith("_"):
|
||||||
|
return getattr(getattr(js_modules, self.name), field)
|
||||||
|
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, js_import
|
||||||
|
|
||||||
|
window = globalThis
|
||||||
|
document = globalThis.document
|
||||||
|
sync = NotSupported(
|
||||||
|
"pyscript.sync", "pyscript.sync works only when running in a worker"
|
||||||
|
)
|
||||||
|
|
||||||
|
# in MAIN the current element target exist, just use it
|
||||||
|
def current_target():
|
||||||
|
return _pyscript.target
|
||||||
@@ -1,247 +1,87 @@
|
|||||||
"""
|
|
||||||
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,
|
||||||
Represents a media input or output device.
|
camera, or headset.
|
||||||
|
|
||||||
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):
|
||||||
"""
|
"""
|
||||||
!!! warning
|
Load the device stream.
|
||||||
**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.
|
|
||||||
"""
|
"""
|
||||||
return await cls.request_stream(audio=audio, video=video)
|
options = {}
|
||||||
|
options["audio"] = audio
|
||||||
|
if isinstance(video, bool):
|
||||||
|
options["video"] = video
|
||||||
|
else:
|
||||||
|
options["video"] = {}
|
||||||
|
for k in video:
|
||||||
|
options["video"][k] = video[k]
|
||||||
|
return await window.navigator.mediaDevices.getUserMedia(to_js(options))
|
||||||
|
|
||||||
async def get_stream(self):
|
async def get_stream(self):
|
||||||
"""
|
key = self.kind.replace("input", "").replace("output", "")
|
||||||
Get a media stream from this specific device.
|
options = {key: {"deviceId": {"exact": self.id}}}
|
||||||
|
return await self.load(**options)
|
||||||
```python
|
|
||||||
from pyscript.media import list_devices
|
|
||||||
|
|
||||||
|
|
||||||
# List all devices.
|
async def list_devices() -> list[dict]:
|
||||||
devices = await list_devices()
|
|
||||||
|
|
||||||
# Find a specific camera.
|
|
||||||
my_camera = None
|
|
||||||
for device in devices:
|
|
||||||
if device.kind == "videoinput" and "USB" in device.label:
|
|
||||||
my_camera = device
|
|
||||||
break
|
|
||||||
|
|
||||||
# Get a stream from that specific camera.
|
|
||||||
if my_camera:
|
|
||||||
stream = await my_camera.get_stream()
|
|
||||||
```
|
|
||||||
|
|
||||||
This will trigger a permission dialog if the user hasn't already
|
|
||||||
granted permission for this device type.
|
|
||||||
"""
|
|
||||||
# Extract media type from device kind (e.g., "videoinput" -> "video").
|
|
||||||
media_type = self.kind.replace("input", "").replace("output", "")
|
|
||||||
# Request stream with exact device ID constraint.
|
|
||||||
options = {media_type: {"deviceId": {"exact": self.id}}}
|
|
||||||
return await self.request_stream(**options)
|
|
||||||
|
|
||||||
|
|
||||||
async def list_devices():
|
|
||||||
"""
|
"""
|
||||||
Returns a list of all media devices currently available to the browser,
|
Return the list of the currently available media input and output devices,
|
||||||
such as microphones, cameras, and speakers.
|
such as microphones, cameras, headsets, and so forth.
|
||||||
|
|
||||||
```python
|
Output:
|
||||||
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).
|
||||||
|
|
||||||
# Get all devices.
|
* groupId: a string that is a group identifier. Two devices have the same
|
||||||
devices = await list_devices()
|
group identifier if they belong to the same physical device — for
|
||||||
|
example a monitor with both a built-in camera and a microphone.
|
||||||
|
|
||||||
# Print device information.
|
* kind: an enumerated value that is either "videoinput", "audioinput"
|
||||||
for device in devices:
|
or "audiooutput".
|
||||||
print(f"{device.kind}: {device.label} (ID: {device.id})")
|
|
||||||
|
|
||||||
# Filter for specific device types.
|
* label: a string describing this device (for example "External USB
|
||||||
cameras = [d for d in devices if d.kind == "videoinput"]
|
Webcam").
|
||||||
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
|
Note: the returned list will omit any devices that are blocked by the document
|
||||||
[Permission Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Permissions_Policy)
|
Permission Policy: microphone, camera, speaker-selection (for output devices),
|
||||||
(microphone, camera, speaker-selection) or for
|
and so on. Access to particular non-default devices is also gated by the
|
||||||
which the user has not granted explicit permission.
|
Permissions API, and the list will omit devices 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()
|
# https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices
|
||||||
return [Device(device_info) for device_info in device_infos]
|
return [
|
||||||
|
Device(obj) for obj in await window.navigator.mediaDevices.enumerateDevices()
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,70 +1,11 @@
|
|||||||
"""
|
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
|
|
||||||
|
|
||||||
|
|
||||||
def _convert_to_idb(value):
|
# convert a Python value into an IndexedDB compatible entry
|
||||||
"""
|
def _to_idb(value):
|
||||||
Convert a Python `value` to an IndexedDB-compatible format.
|
if value is None:
|
||||||
|
|
||||||
Values are serialized using Flatted (for circular reference support)
|
|
||||||
with type information to enable proper deserialization. It returns a
|
|
||||||
JSON string representing the serialized value.
|
|
||||||
|
|
||||||
Will raise a TypeError if the value type is not supported.
|
|
||||||
"""
|
|
||||||
if is_none(value):
|
|
||||||
return _stringify(["null", 0])
|
return _stringify(["null", 0])
|
||||||
if isinstance(value, (bool, float, int, str, list, dict, tuple)):
|
if isinstance(value, (bool, float, int, str, list, dict, tuple)):
|
||||||
return _stringify(["generic", value])
|
return _stringify(["generic", value])
|
||||||
@@ -72,179 +13,50 @@ def _convert_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)])
|
||||||
raise TypeError(f"Cannot serialize type {type(value).__name__} for storage.")
|
msg = f"Unexpected value: {value}"
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
|
|
||||||
def _convert_from_idb(value):
|
# convert an IndexedDB compatible entry into a Python value
|
||||||
"""
|
def _from_idb(value):
|
||||||
Convert an IndexedDB `value` back to its Python representation.
|
(
|
||||||
|
kind,
|
||||||
Uses type information stored during serialization to reconstruct the
|
result,
|
||||||
original Python type.
|
) = _parse(value)
|
||||||
"""
|
|
||||||
kind, data = _parse(value)
|
|
||||||
|
|
||||||
if kind == "null":
|
if kind == "null":
|
||||||
return None
|
return None
|
||||||
if kind == "generic":
|
if kind == "generic":
|
||||||
return data
|
return result
|
||||||
if kind == "bytearray":
|
if kind == "bytearray":
|
||||||
return bytearray(data)
|
return bytearray(result)
|
||||||
if kind == "memoryview":
|
if kind == "memoryview":
|
||||||
return memoryview(bytearray(data))
|
return memoryview(bytearray(result))
|
||||||
# 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()})
|
||||||
Create a Storage instance wrapping an IndexedDB `store` (a JS
|
self.__store__ = store
|
||||||
proxy).
|
|
||||||
"""
|
|
||||||
super().__init__(
|
|
||||||
{key: _convert_from_idb(value) for key, value in store.entries()}
|
|
||||||
)
|
|
||||||
self._store = store
|
|
||||||
|
|
||||||
def __delitem__(self, key):
|
def __delitem__(self, attr):
|
||||||
"""
|
self.__store__.delete(attr)
|
||||||
Delete an item from storage via its `key`.
|
super().__delitem__(attr)
|
||||||
|
|
||||||
The deletion is queued for persistence. Use `sync()` to ensure
|
def __setitem__(self, attr, value):
|
||||||
immediate completion.
|
self.__store__.set(attr, _to_idb(value))
|
||||||
"""
|
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:
|
||||||
raise ValueError("Storage name must be a non-empty string")
|
msg = "The storage name must be defined"
|
||||||
|
raise ValueError(msg)
|
||||||
underlying_store = await _polyscript_storage(f"@pyscript/{name}")
|
return storage_class(await _storage(f"@pyscript/{name}"))
|
||||||
return storage_class(underlying_store)
|
|
||||||
|
|||||||
@@ -1,24 +1,11 @@
|
|||||||
"""
|
|
||||||
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)
|
||||||
@@ -55,25 +42,17 @@ 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. This is interpreter agnostic.
|
function. (MicroPython treats awaitables as generator functions, and if
|
||||||
|
the object is a closure containing an async function we need to work
|
||||||
!!! info
|
carefully.)
|
||||||
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":
|
if config["type"] == "mpy": # Is MicroPython?
|
||||||
# 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.
|
||||||
r = repr(obj)
|
if "<closure <generator>" in 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
@@ -1,295 +1,71 @@
|
|||||||
"""
|
|
||||||
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
|
||||||
|
|
||||||
|
code = "code"
|
||||||
|
protocols = "protocols"
|
||||||
|
reason = "reason"
|
||||||
|
methods = ["onclose", "onerror", "onmessage", "onopen"]
|
||||||
|
|
||||||
|
|
||||||
def _attach_event_handler(websocket, handler_name, handler_function):
|
class EventMessage:
|
||||||
"""
|
|
||||||
Given a `websocket`, and `handler_name`, attach the `handler_function`
|
|
||||||
to the `WebSocket` instance, handling both synchronous and asynchronous
|
|
||||||
handler functions.
|
|
||||||
|
|
||||||
Creates a JavaScript proxy for the handler and wraps async handlers
|
|
||||||
appropriately. Handles the `WebSocketEvent` wrapping for all handlers.
|
|
||||||
"""
|
|
||||||
if is_awaitable(handler_function):
|
|
||||||
|
|
||||||
async def async_wrapper(event):
|
|
||||||
await handler_function(WebSocketEvent(event))
|
|
||||||
|
|
||||||
wrapped_handler = create_proxy(async_wrapper)
|
|
||||||
else:
|
|
||||||
wrapped_handler = create_proxy(
|
|
||||||
lambda event: handler_function(WebSocketEvent(event))
|
|
||||||
)
|
|
||||||
# Note: Direct assignment (websocket[handler_name]) fails in Pyodide.
|
|
||||||
setattr(websocket, handler_name, wrapped_handler)
|
|
||||||
|
|
||||||
|
|
||||||
class WebSocketEvent:
|
|
||||||
"""
|
|
||||||
A read-only wrapper for
|
|
||||||
[WebSocket event objects](https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent).
|
|
||||||
|
|
||||||
This class wraps browser WebSocket events and provides convenient access
|
|
||||||
to event properties. It handles the conversion of binary data from
|
|
||||||
JavaScript typed arrays to Python bytes-like objects.
|
|
||||||
|
|
||||||
The most commonly used property is `event.data`, which contains the
|
|
||||||
message data for "message" events.
|
|
||||||
|
|
||||||
```python
|
|
||||||
def on_message(event): # The event is a WebSocketEvent instance.
|
|
||||||
# For text messages.
|
|
||||||
if isinstance(event.data, str):
|
|
||||||
print(f"Text: {event.data}")
|
|
||||||
else:
|
|
||||||
# For binary messages.
|
|
||||||
print(f"Binary: {len(event.data)} bytes")
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, event):
|
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):
|
||||||
"""
|
|
||||||
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)
|
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()
|
||||||
else:
|
# shims in MicroPython
|
||||||
# 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, url, protocols=None, **handlers):
|
def __init__(self, **kw):
|
||||||
"""
|
url = kw["url"]
|
||||||
Create a new WebSocket connection from the given `url` (`ws://` or
|
if protocols in kw:
|
||||||
`wss://`). Optionally specify `protocols` (a string or a list of
|
socket = js.WebSocket.new(url, kw[protocols])
|
||||||
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:
|
||||||
js_websocket = js.WebSocket.new(url)
|
socket = js.WebSocket.new(url)
|
||||||
# Set binary type to arraybuffer for easier Python handling.
|
object.__setattr__(self, "_ws", socket)
|
||||||
js_websocket.binaryType = "arraybuffer"
|
|
||||||
# Store the underlying WebSocket.
|
for t in methods:
|
||||||
# Use object.__setattr__ to bypass our custom __setattr__.
|
if t in kw:
|
||||||
object.__setattr__(self, "_js_websocket", js_websocket)
|
# Pyodide fails at setting socket[t] directly
|
||||||
# Attach any event handlers passed as keyword arguments.
|
setattr(socket, t, create_proxy(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:
|
||||||
Set an attribute `attr` on the WebSocket to the given `value`.
|
m = lambda e: value(EventMessage(e))
|
||||||
|
setattr(self._ws, attr, create_proxy(m))
|
||||||
Event handler attributes (`onopen`, `onmessage`, etc.) are specially
|
|
||||||
handled to create proper proxies. Other attributes are set on the
|
|
||||||
underlying WebSocket directly.
|
|
||||||
"""
|
|
||||||
if attr in ["onclose", "onerror", "onmessage", "onopen"]:
|
|
||||||
_attach_event_handler(self._js_websocket, attr, value)
|
|
||||||
else:
|
else:
|
||||||
setattr(self._js_websocket, attr, value)
|
setattr(self._ws, attr, value)
|
||||||
|
|
||||||
|
def close(self, **kw):
|
||||||
|
if code in kw and reason in kw:
|
||||||
|
self._ws.close(kw[code], kw[reason])
|
||||||
|
elif code in kw:
|
||||||
|
self._ws.close(kw[code])
|
||||||
|
else:
|
||||||
|
self._ws.close()
|
||||||
|
|
||||||
def send(self, data):
|
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._js_websocket.send(data)
|
self._ws.send(data)
|
||||||
else:
|
else:
|
||||||
buffer = js.Uint8Array.new(len(data))
|
buffer = js.Uint8Array.new(len(data))
|
||||||
for index, byte_value in enumerate(data):
|
for pos, b in enumerate(data):
|
||||||
buffer[index] = byte_value
|
buffer[pos] = b
|
||||||
self._js_websocket.send(buffer)
|
self._ws.send(buffer)
|
||||||
|
|
||||||
def close(self, code=None, reason=None):
|
|
||||||
"""
|
|
||||||
Close the WebSocket connection. Optionally specify a `code` (`int`)
|
|
||||||
and a `reason` (`str`) for closing the connection.
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Normal close.
|
|
||||||
ws.close()
|
|
||||||
|
|
||||||
# Close with code and reason.
|
|
||||||
ws.close(code=1000, reason="Task completed")
|
|
||||||
```
|
|
||||||
|
|
||||||
Usage and values for `code` and `reasons`
|
|
||||||
[are explained here](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close).
|
|
||||||
"""
|
|
||||||
if code and reason:
|
|
||||||
self._js_websocket.close(code, reason)
|
|
||||||
elif code:
|
|
||||||
self._js_websocket.close(code)
|
|
||||||
else:
|
|
||||||
self._js_websocket.close()
|
|
||||||
|
|||||||
@@ -1,194 +1,45 @@
|
|||||||
"""
|
import js as _js
|
||||||
This module provides access to named
|
from polyscript import workers as _workers
|
||||||
[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.
|
|
||||||
|
|
||||||
Named workers are Python web workers defined in HTML with a `name` attribute
|
_get = _js.Reflect.get
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
calc = await workers["calculator"]
|
def _set(script, name, value=""):
|
||||||
result = await calc.add(5, 3)
|
script.setAttribute(name, value)
|
||||||
print(result) # 8
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
Dynamically creating named workers:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pyscript import create_named_worker
|
|
||||||
|
|
||||||
|
|
||||||
# Create a worker from a Python file.
|
# this solves an inconsistency between Pyodide and MicroPython
|
||||||
worker = await create_named_worker(
|
# @see https://github.com/pyscript/pyscript/issues/2106
|
||||||
src="./background_tasks.py",
|
class _ReadOnlyProxy:
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
# Global workers proxy for accessing named workers.
|
workers = _ReadOnlyProxy()
|
||||||
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`).
|
|
||||||
|
|
||||||
This function creates a new web worker by injecting a `<script>` tag into
|
if not src:
|
||||||
the document. The worker will be accessible via the `workers` proxy once
|
msg = "Named workers require src"
|
||||||
it's ready.
|
raise ValueError(msg)
|
||||||
|
|
||||||
It returns a promise that resolves to the worker reference when ready.
|
if not name:
|
||||||
|
msg = "Named workers require a name"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
```python
|
s = _js.document.createElement("script")
|
||||||
from pyscript import create_named_worker
|
s.type = type
|
||||||
|
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:
|
||||||
if isinstance(config, str):
|
_set(s, "config", (isinstance(config, str) and config) or dumps(config))
|
||||||
config_str = config
|
|
||||||
else:
|
_js.document.body.append(s)
|
||||||
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]
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
|
import { ArrayBuffer, TypedArray } from "sabayon/shared";
|
||||||
import IDBMapSync from "@webreflection/idb-map/sync";
|
import IDBMapSync from "@webreflection/idb-map/sync";
|
||||||
import { parse, stringify } from "flatted";
|
import { parse, stringify } from "flatted";
|
||||||
|
|
||||||
const { isView } = ArrayBuffer;
|
|
||||||
|
|
||||||
const to_idb = (value) => {
|
const to_idb = (value) => {
|
||||||
if (value == null) return stringify(["null", 0]);
|
if (value == null) return stringify(["null", 0]);
|
||||||
/* eslint-disable no-fallthrough */
|
/* eslint-disable no-fallthrough */
|
||||||
switch (typeof value) {
|
switch (typeof value) {
|
||||||
case "object": {
|
case "object": {
|
||||||
if (isView(value)) return stringify(["memoryview", [...value]]);
|
if (value instanceof TypedArray)
|
||||||
|
return stringify(["memoryview", [...value]]);
|
||||||
if (value instanceof ArrayBuffer)
|
if (value instanceof ArrayBuffer)
|
||||||
return stringify(["bytearray", [...new Uint8Array(value)]]);
|
return stringify(["bytearray", [...new Uint8Array(value)]]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,33 +12,20 @@ export default {
|
|||||||
return new Promise(($) => setTimeout($, seconds * 1000));
|
return new Promise(($) => setTimeout($, seconds * 1000));
|
||||||
},
|
},
|
||||||
|
|
||||||
getFSHandler: (options) => getFileSystemDirectoryHandle(options),
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ask a user action via dialog and returns the directory handler once granted.
|
* Ask a user action via dialog and returns the directory handler once granted.
|
||||||
* @param {string} uid
|
* @param {string} uid
|
||||||
* @param {{id?:string, mode?:"read"|"readwrite", hint?:"desktop"|"documents"|"downloads"|"music"|"pictures"|"videos"}} options
|
* @param {{id?:string, mode?:"read"|"readwrite", hint?:"desktop"|"documents"|"downloads"|"music"|"pictures"|"videos"}} options
|
||||||
* @returns {Promise<boolean>}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
async storeFSHandler(uid, options = {}) {
|
async storeFSHandler(uid, options = {}) {
|
||||||
if (await idb.has(uid)) return true;
|
if (await idb.has(uid)) return true;
|
||||||
return getFileSystemDirectoryHandle(options).then(
|
return getFileSystemDirectoryHandle(options).then(
|
||||||
async (handler) => {
|
async (handler) => {
|
||||||
await idb.set(uid, { handler, options });
|
await idb.set(uid, handler);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
() => false,
|
() => false,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Explicitly remove the unique identifier for the FS handler.
|
|
||||||
* @param {string} uid
|
|
||||||
* @returns {Promise<boolean>}
|
|
||||||
*/
|
|
||||||
async deleteFSHandler(uid) {
|
|
||||||
const had = await idb.has(uid);
|
|
||||||
if (had) await idb.delete(uid);
|
|
||||||
return had;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
/* eslint no-unused-vars: 0 */
|
|
||||||
try {
|
|
||||||
crypto.randomUUID();
|
|
||||||
} catch (_) {
|
|
||||||
if (location.href.startsWith("http://0.0.0.0"))
|
|
||||||
location.href = location.href.replace("0.0.0.0", "localhost");
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
print("OK")
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<link rel="stylesheet" href="../../../dist/core.css">
|
|
||||||
<script type="module" src="../../../dist/core.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<py-config config-parser="https://esm.run/basic-toml">
|
|
||||||
[files]
|
|
||||||
'file.py' = ""
|
|
||||||
</py-config>
|
|
||||||
<script type="py">
|
|
||||||
import file
|
|
||||||
from pyscript import document
|
|
||||||
document.documentElement.classList.add("done")
|
|
||||||
document.body.append("OK")
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -2,7 +2,7 @@ from pyscript import document
|
|||||||
|
|
||||||
classList = document.documentElement.classList
|
classList = document.documentElement.classList
|
||||||
|
|
||||||
if not __terminal__: # noqa: F821 __terminal__ is defined in core/src/plugins/donkey.js
|
if not __terminal__:
|
||||||
classList.add("error")
|
classList.add("error")
|
||||||
else:
|
else:
|
||||||
classList.add("ok")
|
classList.add("ok")
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"packages": ["numpy"]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<link rel="stylesheet" href="../../../dist/core.css">
|
|
||||||
<script type="module" src="../../../dist/core.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<script type="mpy">
|
|
||||||
from pyscript import PyWorker
|
|
||||||
worker = PyWorker("./worker.py", type="pyodide", config="./config.json")
|
|
||||||
await worker.ready
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import numpy
|
|
||||||
from pyscript import document
|
|
||||||
|
|
||||||
document.body.append(numpy.__version__)
|
|
||||||
document.documentElement.classList.add("done")
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>PyScript VS Symbols</title>
|
|
||||||
<script>
|
|
||||||
globalThis.hasSymbol = (symbol, ref) => symbol in ref;
|
|
||||||
globalThis.getSymbol = (symbol, ref) => ref[symbol];
|
|
||||||
|
|
||||||
// some 3rd party JS library might use symbols to brand-check
|
|
||||||
// so it's not about symbols traveling from MicroPython
|
|
||||||
// it's about MicroPython proxies traps not understanding symbols
|
|
||||||
globalThis.hasIterator = ref => Symbol.iterator in ref;
|
|
||||||
</script>
|
|
||||||
<link rel="stylesheet" href="../../dist/core.css">
|
|
||||||
<script type="module" src="../../dist/core.js"></script>
|
|
||||||
<script type="mpy">
|
|
||||||
import js
|
|
||||||
|
|
||||||
symbol = js.Symbol.iterator
|
|
||||||
|
|
||||||
if js.getSymbol(symbol, []) and js.hasSymbol(symbol, []) and js.hasIterator([]):
|
|
||||||
js.document.documentElement.classList.add("main")
|
|
||||||
</script>
|
|
||||||
<script type="mpy" worker>
|
|
||||||
from pyscript import window
|
|
||||||
import js
|
|
||||||
|
|
||||||
symbol = js.Symbol.iterator
|
|
||||||
|
|
||||||
if window.getSymbol(symbol, []) and window.hasSymbol(symbol, []) and window.hasIterator([]):
|
|
||||||
window.document.documentElement.classList.add("worker")
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
</html>
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
print(event.type)
|
print(event.type)
|
||||||
ws.send("hello")
|
ws.send("hello")
|
||||||
|
|
||||||
async def onmessage(event):
|
def onmessage(event):
|
||||||
print(event.type, event.data)
|
print(event.type, event.data)
|
||||||
ws.close()
|
ws.close()
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,6 @@ import { test, expect } from '@playwright/test';
|
|||||||
|
|
||||||
test.setTimeout(120 * 1000);
|
test.setTimeout(120 * 1000);
|
||||||
|
|
||||||
test('Worker config as relative URL', async ({ page }) => {
|
|
||||||
await page.goto('http://localhost:8080/tests/javascript/worker-config/index.html');
|
|
||||||
await page.waitForSelector('html.done');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('config-parser custom TOML', async ({ page }) => {
|
|
||||||
await page.goto('http://localhost:8080/tests/javascript/config-parser/index.html');
|
|
||||||
await page.waitForSelector('html.done');
|
|
||||||
const body = await page.evaluate(() => document.body.innerText);
|
|
||||||
await expect(body.trim()).toBe('OK');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('MicroPython display', async ({ page }) => {
|
test('MicroPython display', async ({ page }) => {
|
||||||
await page.goto('http://localhost:8080/tests/javascript/mpy.html');
|
await page.goto('http://localhost:8080/tests/javascript/mpy.html');
|
||||||
await page.waitForSelector('html.done.worker');
|
await page.waitForSelector('html.done.worker');
|
||||||
@@ -71,11 +59,6 @@ test('MicroPython + configURL', async ({ page }) => {
|
|||||||
await page.waitForSelector('html.main.worker');
|
await page.waitForSelector('html.main.worker');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('MicroPython + Symbols', async ({ page }) => {
|
|
||||||
await page.goto('http://localhost:8080/tests/javascript/worker-symbols.html');
|
|
||||||
await page.waitForSelector('html.main.worker');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Pyodide + terminal on Main', async ({ page }) => {
|
test('Pyodide + terminal on Main', async ({ page }) => {
|
||||||
await page.goto('http://localhost:8080/tests/javascript/py-terminal-main.html');
|
await page.goto('http://localhost:8080/tests/javascript/py-terminal-main.html');
|
||||||
await page.waitForSelector('html.ok');
|
await page.waitForSelector('html.ok');
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<link rel="stylesheet" href="../../../dist/core.css">
|
|
||||||
<script>
|
|
||||||
window.Worker = class extends Worker {
|
|
||||||
constructor(url, ...rest) {
|
|
||||||
console.log(rest[0]);
|
|
||||||
return super(url, ...rest);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.start = Date.now();
|
|
||||||
</script>
|
|
||||||
<script type="module" src="../../../dist/core.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<script type="py" config="./index.toml" src="index.py" worker></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from pyscript import document, window
|
|
||||||
|
|
||||||
document.body.append(window.Date.now() - window.start)
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
experimental_ffi_timeout = 0
|
|
||||||
package_cache = "passthrough"
|
|
||||||
@@ -19,8 +19,6 @@ if TEST == "implicit":
|
|||||||
|
|
||||||
await fs.sync("/persistent")
|
await fs.sync("/persistent")
|
||||||
|
|
||||||
# await fs.revoke("/persistent")
|
|
||||||
|
|
||||||
elif not RUNNING_IN_WORKER:
|
elif not RUNNING_IN_WORKER:
|
||||||
from pyscript import document
|
from pyscript import document
|
||||||
|
|
||||||
@@ -41,7 +39,7 @@ elif not RUNNING_IN_WORKER:
|
|||||||
js.alert("unable to grant access")
|
js.alert("unable to grant access")
|
||||||
|
|
||||||
async def unmount(event):
|
async def unmount(event):
|
||||||
await fs.revoke("/persistent")
|
await fs.unmount("/persistent")
|
||||||
button.textContent = "mount"
|
button.textContent = "mount"
|
||||||
button.onclick = mount
|
button.onclick = mount
|
||||||
|
|
||||||
|
|||||||
@@ -318,12 +318,16 @@ async def main(winstyle=0):
|
|||||||
if not fullscreen:
|
if not fullscreen:
|
||||||
print("Changing to FULLSCREEN")
|
print("Changing to FULLSCREEN")
|
||||||
screen_backup = screen.copy()
|
screen_backup = screen.copy()
|
||||||
screen = pygame.display.set_mode(SCREENRECT.size, winstyle | pygame.FULLSCREEN)
|
screen = pygame.display.set_mode(
|
||||||
|
SCREENRECT.size, winstyle | pygame.FULLSCREEN, bestdepth
|
||||||
|
)
|
||||||
screen.blit(screen_backup, (0, 0))
|
screen.blit(screen_backup, (0, 0))
|
||||||
else:
|
else:
|
||||||
print("Changing to windowed mode")
|
print("Changing to windowed mode")
|
||||||
screen_backup = screen.copy()
|
screen_backup = screen.copy()
|
||||||
screen = pygame.display.set_mode(SCREENRECT.size, winstyle)
|
screen = pygame.display.set_mode(
|
||||||
|
SCREENRECT.size, winstyle, bestdepth
|
||||||
|
)
|
||||||
screen.blit(screen_backup, (0, 0))
|
screen.blit(screen_backup, (0, 0))
|
||||||
pygame.display.flip()
|
pygame.display.flip()
|
||||||
fullscreen = not fullscreen
|
fullscreen = not fullscreen
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ def get_stats_gl(renderer):
|
|||||||
|
|
||||||
def bg_from_v(*vertices):
|
def bg_from_v(*vertices):
|
||||||
geometry = new(THREE.BufferGeometry)
|
geometry = new(THREE.BufferGeometry)
|
||||||
vertices_f32a = new(Float32Array, vertices) # noqa: F821 Float32Array is defined in js
|
vertices_f32a = new(Float32Array, vertices)
|
||||||
attr = new(THREE.Float32BufferAttribute, vertices_f32a, 3)
|
attr = new(THREE.Float32BufferAttribute, vertices_f32a, 3)
|
||||||
return geometry.setAttribute('position', attr)
|
return geometry.setAttribute('position', attr)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from pyscript import document, window, PyWorker
|
|||||||
from libthree import THREE, clear, SoundPlayer
|
from libthree import THREE, clear, SoundPlayer
|
||||||
from libthree import get_renderer, get_ortho_camera
|
from libthree import get_renderer, get_ortho_camera
|
||||||
from libthree import get_loading_manager, get_stats_gl
|
from libthree import get_loading_manager, get_stats_gl
|
||||||
from libthree import line2, linemat, lsgeo
|
from libthree import lsgeo, line2, linemat, lsgeo
|
||||||
from libfft import BeatSync
|
from libfft import BeatSync
|
||||||
|
|
||||||
from multipyjs import MICROPYTHON, new, call, to_js, create_proxy
|
from multipyjs import MICROPYTHON, new, call, to_js, create_proxy
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="stylesheet" href="../../../dist/core.css">
|
<link rel="stylesheet" href="../../dist/core.css">
|
||||||
<script type="module" src="../../../dist/core.js"></script>
|
<script type="module" src="../../dist/core.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script type="py-editor">
|
<script type="py-editor">
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
||||||
<title>PyScript Custom Package</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<script>
|
|
||||||
const track = ({ detail }) => {
|
|
||||||
if (/^(Loading|Loaded)/.test(detail)) {
|
|
||||||
const state = RegExp.$1;
|
|
||||||
const what = detail.slice(state.length + 1);
|
|
||||||
if (state === 'Loading') console.time(what);
|
|
||||||
else console.timeEnd(what);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
addEventListener("py:progress", track);
|
|
||||||
addEventListener("mpy:progress", track);
|
|
||||||
</script>
|
|
||||||
<!-- PyScript (locally) -->
|
|
||||||
<link rel="stylesheet" href="../../../dist/core.css">
|
|
||||||
<script type="module" src="../../../dist/core.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div>loading ...</div>
|
|
||||||
<script type="py" config="./package.toml">
|
|
||||||
import custom_package
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
experimental_remote_packages = true
|
|
||||||
|
|
||||||
packages = ['https://webreflection.github.io/examples/pyscript/module/package.toml']
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
const timeout = 120 * 1000;
|
const timeout = 60 * 1000;
|
||||||
|
|
||||||
test.setTimeout(timeout);
|
test.setTimeout(timeout);
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,6 @@
|
|||||||
|
|
||||||
<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>
|
||||||
@@ -65,7 +63,6 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
{
|
{
|
||||||
"files": {
|
"files": {
|
||||||
"https://raw.githubusercontent.com/ntoll/upytest/1.0.11/upytest.py": "",
|
"https://raw.githubusercontent.com/ntoll/upytest/1.0.9/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_js_modules.py": "tests/test_js_modules.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_window.py": "tests/test_window.py",
|
"./tests/test_events.py": "tests/test_events.py",
|
||||||
"./tests/test_workers.py": "tests/test_workers.py"
|
"./tests/test_window.py": "tests/test_window.py"
|
||||||
},
|
},
|
||||||
"js_modules": {
|
"js_modules": {
|
||||||
"main": {"./example_js_module.js": "greeting"},
|
"main": {
|
||||||
"worker": {"./example_js_worker_module.js": "greeting_worker"}
|
"./example_js_module.js": "greeting"
|
||||||
|
},
|
||||||
|
"worker": {
|
||||||
|
"./example_js_worker_module.js": "greeting_worker"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
{
|
{
|
||||||
"files": {
|
"files": {
|
||||||
"https://raw.githubusercontent.com/ntoll/upytest/1.0.11/upytest.py": "",
|
"https://raw.githubusercontent.com/ntoll/upytest/1.0.9/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_window.py": "tests/test_window.py",
|
"./tests/test_events.py": "tests/test_events.py",
|
||||||
"./tests/test_workers.py": "tests/test_workers.py"
|
"./tests/test_window.py": "tests/test_window.py"
|
||||||
},
|
},
|
||||||
"js_modules": {
|
"js_modules": {
|
||||||
"main": {"./example_js_module.js": "greeting"},
|
"main": {
|
||||||
"worker": {"./example_js_worker_module.js": "greeting_worker"}
|
"./example_js_module.js": "greeting"
|
||||||
|
},
|
||||||
|
"worker": {
|
||||||
|
"./example_js_worker_module.js": "greeting_worker"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"packages": ["Pillow"],
|
"packages": ["Pillow" ]
|
||||||
"experimental_ffi_timeout": 0
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ def test_current_target():
|
|||||||
"""
|
"""
|
||||||
expected = "py-0"
|
expected = "py-0"
|
||||||
if is_micropython:
|
if is_micropython:
|
||||||
expected = "mpy-w1-target" if RUNNING_IN_WORKER else "mpy-0"
|
expected = "mpy-w0-target" if RUNNING_IN_WORKER else "mpy-0"
|
||||||
elif RUNNING_IN_WORKER:
|
elif RUNNING_IN_WORKER:
|
||||||
expected = "py-w1-target"
|
expected = "py-w0-target"
|
||||||
assert current_target() == expected, f"Expected {expected} got {current_target()}"
|
assert current_target() == expected, f"Expected {expected} got {current_target()}"
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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
|
||||||
@@ -108,7 +107,20 @@ 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 find element with id='' in the page."
|
assert str(exc.exception) == "Cannot have an empty target"
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_string_target_values_raise_typerror():
|
||||||
|
"""
|
||||||
|
The target parameter must be a string.
|
||||||
|
"""
|
||||||
|
with upytest.raises(TypeError) as exc:
|
||||||
|
display("hello world", target=True)
|
||||||
|
assert str(exc.exception) == "target must be str or None, not bool"
|
||||||
|
|
||||||
|
with upytest.raises(TypeError) as exc:
|
||||||
|
display("hello world", target=123)
|
||||||
|
assert str(exc.exception) == "target must be str or None, not int"
|
||||||
|
|
||||||
|
|
||||||
async def test_tag_target_attribute():
|
async def test_tag_target_attribute():
|
||||||
@@ -274,406 +286,4 @@ 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;base64"), img.src
|
assert img.src.startswith("data:image/png;charset=utf-8;base64")
|
||||||
|
|
||||||
|
|
||||||
async def test_mimebundle_simple():
|
|
||||||
"""
|
|
||||||
An object with _repr_mimebundle_ should use the mimebundle formats.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class MimebundleObj:
|
|
||||||
def _repr_mimebundle_(self):
|
|
||||||
return {
|
|
||||||
"text/html": "<strong>Bold HTML</strong>",
|
|
||||||
"text/plain": "Plain text fallback",
|
|
||||||
}
|
|
||||||
|
|
||||||
display(MimebundleObj())
|
|
||||||
container = await get_display_container()
|
|
||||||
# Should prefer HTML from mimebundle.
|
|
||||||
assert container[0].innerHTML == "<strong>Bold HTML</strong>"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_mimebundle_with_metadata():
|
|
||||||
"""
|
|
||||||
Mimebundle can include metadata for specific MIME types.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class ImageWithMeta:
|
|
||||||
def _repr_mimebundle_(self):
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"image/png": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
|
||||||
},
|
|
||||||
{"image/png": {"width": "100", "height": "50"}},
|
|
||||||
)
|
|
||||||
|
|
||||||
display(ImageWithMeta(), target="test-element-container", append=False)
|
|
||||||
target = web.page.find("#test-element-container")[0]
|
|
||||||
img = target.find("img")[0]
|
|
||||||
assert img.getAttribute("width") == "100"
|
|
||||||
assert img.getAttribute("height") == "50"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_mimebundle_with_tuple_output():
|
|
||||||
"""
|
|
||||||
Mimebundle format values can be tuples with (data, metadata).
|
|
||||||
"""
|
|
||||||
|
|
||||||
class TupleOutput:
|
|
||||||
def _repr_mimebundle_(self):
|
|
||||||
return {"text/html": ("<em>Italic</em>", {"custom": "meta"})}
|
|
||||||
|
|
||||||
display(TupleOutput())
|
|
||||||
container = await get_display_container()
|
|
||||||
assert container[0].innerHTML == "<em>Italic</em>"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_mimebundle_metadata_merge():
|
|
||||||
"""
|
|
||||||
Format-specific metadata should merge with global metadata.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class MetaMerge:
|
|
||||||
def _repr_mimebundle_(self):
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
"image/png": (
|
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
|
||||||
{"height": "75"},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{"image/png": {"width": "100"}},
|
|
||||||
)
|
|
||||||
|
|
||||||
display(MetaMerge(), target="test-element-container", append=False)
|
|
||||||
target = web.page.find("#test-element-container")[0]
|
|
||||||
img = target.find("img")[0]
|
|
||||||
# Both global and format-specific metadata should be present.
|
|
||||||
assert img.getAttribute("width") == "100"
|
|
||||||
assert img.getAttribute("height") == "75"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_mimebundle_unsupported_mime():
|
|
||||||
"""
|
|
||||||
If mimebundle contains only unsupported MIME types, fall back to regular methods.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class UnsupportedMime:
|
|
||||||
def _repr_mimebundle_(self):
|
|
||||||
return {"application/pdf": "PDF data", "text/latex": "LaTeX data"}
|
|
||||||
|
|
||||||
def _repr_html_(self):
|
|
||||||
return "<p>HTML fallback</p>"
|
|
||||||
|
|
||||||
display(UnsupportedMime())
|
|
||||||
container = await get_display_container()
|
|
||||||
# Should fall back to _repr_html_.
|
|
||||||
assert container[0].innerHTML == "<p>HTML fallback</p>"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_mimebundle_no_dict():
|
|
||||||
"""
|
|
||||||
Mimebundle that returns just a dict (no tuple) should work.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class SimpleMimebundle:
|
|
||||||
def _repr_mimebundle_(self):
|
|
||||||
return {"text/html": "<code>Code</code>"}
|
|
||||||
|
|
||||||
display(SimpleMimebundle())
|
|
||||||
container = await get_display_container()
|
|
||||||
assert container[0].innerHTML == "<code>Code</code>"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_repr_html():
|
|
||||||
"""
|
|
||||||
Objects with _repr_html_ should render as HTML.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class HTMLRepr:
|
|
||||||
def _repr_html_(self):
|
|
||||||
return "<h1>HTML Header</h1>"
|
|
||||||
|
|
||||||
display(HTMLRepr())
|
|
||||||
container = await get_display_container()
|
|
||||||
assert container[0].innerHTML == "<h1>HTML Header</h1>"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_repr_html_with_metadata():
|
|
||||||
"""
|
|
||||||
_repr_html_ can return (html, metadata) tuple.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class HTMLWithMeta:
|
|
||||||
def _repr_html_(self):
|
|
||||||
return ("<p>Paragraph</p>", {"data-custom": "value"})
|
|
||||||
|
|
||||||
display(HTMLWithMeta())
|
|
||||||
container = await get_display_container()
|
|
||||||
# Metadata is not used in _repr_html_ rendering, but ensure HTML is
|
|
||||||
# correct.
|
|
||||||
assert container[0].innerHTML == "<p>Paragraph</p>"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_repr_svg():
|
|
||||||
"""
|
|
||||||
Objects with _repr_svg_ should render as SVG.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class SVGRepr:
|
|
||||||
def _repr_svg_(self):
|
|
||||||
return '<svg width="100" height="100"><circle cx="50" cy="50" r="40" fill="blue"/></svg>'
|
|
||||||
|
|
||||||
display(SVGRepr(), target="test-element-container", append=False)
|
|
||||||
target = web.page.find("#test-element-container")[0]
|
|
||||||
assert "svg" in target.innerHTML.lower()
|
|
||||||
assert "circle" in target.innerHTML.lower()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_repr_json():
|
|
||||||
"""
|
|
||||||
Objects with _repr_json_ should render as JSON.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class JSONRepr:
|
|
||||||
def _repr_json_(self):
|
|
||||||
return '{"key": "value", "number": 42}'
|
|
||||||
|
|
||||||
display(JSONRepr(), target="test-element-container", append=False)
|
|
||||||
target = web.page.find("#test-element-container")[0]
|
|
||||||
assert '"key": "value"' in target.innerHTML
|
|
||||||
value = json.loads(target.innerText)
|
|
||||||
assert value["key"] == "value"
|
|
||||||
assert value["number"] == 42
|
|
||||||
|
|
||||||
|
|
||||||
async def test_repr_png_bytes():
|
|
||||||
"""
|
|
||||||
_repr_png_ can render raw bytes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class PNGBytes:
|
|
||||||
def _repr_png_(self):
|
|
||||||
# Valid 1x1 transparent PNG as bytes.
|
|
||||||
return b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
|
|
||||||
|
|
||||||
display(PNGBytes(), target="test-element-container", append=False)
|
|
||||||
target = web.page.find("#test-element-container")[0]
|
|
||||||
img = target.find("img")[0]
|
|
||||||
assert img.src.startswith("data:image/png;base64,")
|
|
||||||
|
|
||||||
|
|
||||||
async def test_repr_png_base64():
|
|
||||||
"""
|
|
||||||
_repr_png_ can render a base64-encoded string.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class PNGBase64:
|
|
||||||
def _repr_png_(self):
|
|
||||||
return "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
|
||||||
|
|
||||||
display(PNGBase64(), target="test-element-container", append=False)
|
|
||||||
target = web.page.find("#test-element-container")[0]
|
|
||||||
img = target.find("img")[0]
|
|
||||||
assert img.src.startswith("data:image/png;base64,")
|
|
||||||
|
|
||||||
|
|
||||||
async def test_repr_jpeg():
|
|
||||||
"""
|
|
||||||
Objects with _repr_jpeg_ should render as JPEG images.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class JPEGRepr:
|
|
||||||
def _repr_jpeg_(self):
|
|
||||||
# Minimal valid JPEG header (won't display but tests the path).
|
|
||||||
return b"\xff\xd8\xff\xe0\x00\x10JFIF"
|
|
||||||
|
|
||||||
display(JPEGRepr(), target="test-element-container", append=False)
|
|
||||||
target = web.page.find("#test-element-container")[0]
|
|
||||||
img = target.find("img")[0]
|
|
||||||
assert img.src.startswith("data:image/jpeg;base64,")
|
|
||||||
|
|
||||||
|
|
||||||
async def test_repr_jpeg_base64():
|
|
||||||
"""
|
|
||||||
_repr_jpeg_ can render a base64-encoded string.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class JPEGBase64:
|
|
||||||
def _repr_jpeg_(self):
|
|
||||||
return "ZCBqcGVnIG1pbmltdW0=="
|
|
||||||
|
|
||||||
display(JPEGBase64(), target="test-element-container", append=False)
|
|
||||||
target = web.page.find("#test-element-container")[0]
|
|
||||||
img = target.find("img")[0]
|
|
||||||
assert img.src.startswith("data:image/jpeg;base64,")
|
|
||||||
|
|
||||||
|
|
||||||
async def test_object_with_no_repr_methods():
|
|
||||||
"""
|
|
||||||
Objects with no representation methods should fall back to __repr__ with warning.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class NoReprMethods:
|
|
||||||
pass
|
|
||||||
|
|
||||||
obj = NoReprMethods()
|
|
||||||
display(obj)
|
|
||||||
container = await get_display_container()
|
|
||||||
# Should contain the default repr output - the class name. :-)
|
|
||||||
assert "NoReprMethods" in container.innerText
|
|
||||||
|
|
||||||
|
|
||||||
async def test_repr_method_returns_none():
|
|
||||||
"""
|
|
||||||
If a repr method exists but returns None, try next method.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class NoneReturner:
|
|
||||||
def _repr_html_(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return "Fallback repr"
|
|
||||||
|
|
||||||
display(NoneReturner())
|
|
||||||
container = await get_display_container()
|
|
||||||
assert container.innerText == "Fallback repr"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_multiple_repr_methods_priority():
|
|
||||||
"""
|
|
||||||
When multiple repr methods exist, should use first available in priority order.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class MultipleReprs:
|
|
||||||
def _repr_html_(self):
|
|
||||||
# Highest priority.
|
|
||||||
return "<p>HTML version</p>"
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
# Lower priority.
|
|
||||||
return "Text version"
|
|
||||||
|
|
||||||
display(MultipleReprs())
|
|
||||||
container = await get_display_container()
|
|
||||||
# Should use HTML, not repr.
|
|
||||||
assert container[0].innerHTML == "<p>HTML version</p>"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_empty_string_display():
|
|
||||||
"""
|
|
||||||
Empty strings are ignored.
|
|
||||||
"""
|
|
||||||
display("")
|
|
||||||
container = await get_display_container()
|
|
||||||
assert len(container.children) == 0
|
|
||||||
|
|
||||||
|
|
||||||
async def test_newline_string_skipped():
|
|
||||||
"""
|
|
||||||
Single newline strings are skipped (legacy behavior).
|
|
||||||
"""
|
|
||||||
display("\n")
|
|
||||||
container = await get_display_container()
|
|
||||||
# Should be empty because newlines are skipped.
|
|
||||||
assert len(container.children) == 0
|
|
||||||
|
|
||||||
|
|
||||||
async def test_string_with_special_html_chars():
|
|
||||||
"""
|
|
||||||
Strings with HTML special characters should be escaped.
|
|
||||||
"""
|
|
||||||
display("<script>alert('xss')</script>")
|
|
||||||
container = await get_display_container()
|
|
||||||
assert "<script>" in container[0].innerHTML
|
|
||||||
assert "<script>" not in container[0].innerHTML
|
|
||||||
|
|
||||||
|
|
||||||
async def test_javascript_mime_type():
|
|
||||||
"""
|
|
||||||
JavaScript MIME type should create script tags.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class JSRepr:
|
|
||||||
def _repr_javascript_(self):
|
|
||||||
return "console.log('test');"
|
|
||||||
|
|
||||||
display(JSRepr(), target="test-element-container", append=False)
|
|
||||||
target = web.page.find("#test-element-container")[0]
|
|
||||||
assert "<script>" in target.innerHTML
|
|
||||||
assert "console.log" in target.innerHTML
|
|
||||||
|
|
||||||
|
|
||||||
async def test_append_false_clears_multiple_children():
|
|
||||||
"""
|
|
||||||
append=False should clear all existing children, not just the last one.
|
|
||||||
"""
|
|
||||||
# Add some initial content.
|
|
||||||
display("child 1")
|
|
||||||
display("child 2")
|
|
||||||
display("child 3")
|
|
||||||
container = await get_display_container()
|
|
||||||
assert len(container.children) == 3 # three divs.
|
|
||||||
|
|
||||||
# Now display with append=False.
|
|
||||||
display("new content", append=False)
|
|
||||||
container = await get_display_container()
|
|
||||||
# No divs used, just the new textual content.
|
|
||||||
assert container.innerText == "new content"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_mixed_append_true_false():
|
|
||||||
"""
|
|
||||||
Mixing append=True and append=False should work correctly.
|
|
||||||
"""
|
|
||||||
display("first", append=True)
|
|
||||||
display("second", append=True)
|
|
||||||
display("third", append=False)
|
|
||||||
container = await get_display_container()
|
|
||||||
assert container.innerText == "third"
|
|
||||||
|
|
||||||
|
|
||||||
def test_target_with_multiple_hashes():
|
|
||||||
"""
|
|
||||||
Target with multiple # characters should only strip the first one.
|
|
||||||
|
|
||||||
Such an id is not valid in HTML, but we should handle it gracefully.
|
|
||||||
"""
|
|
||||||
# Should try to find element with id="#weird-id".
|
|
||||||
# This will raise ValueError as it doesn't exist.
|
|
||||||
with upytest.raises(ValueError):
|
|
||||||
display("content", target="##weird-id")
|
|
||||||
|
|
||||||
|
|
||||||
async def test_display_none_value():
|
|
||||||
"""
|
|
||||||
Displaying None should use its repr.
|
|
||||||
"""
|
|
||||||
display(None)
|
|
||||||
container = await get_display_container()
|
|
||||||
assert container.innerText == "None"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_display_boolean_values():
|
|
||||||
"""
|
|
||||||
Booleans should display as their repr.
|
|
||||||
"""
|
|
||||||
display(True, False)
|
|
||||||
container = await get_display_container()
|
|
||||||
assert "True" in container.innerText
|
|
||||||
assert "False" in container.innerText
|
|
||||||
|
|
||||||
|
|
||||||
async def test_display_numbers():
|
|
||||||
"""
|
|
||||||
Numbers should display correctly.
|
|
||||||
"""
|
|
||||||
display(42, 3.14159, -17)
|
|
||||||
container = await get_display_container()
|
|
||||||
text = container.innerText
|
|
||||||
assert "42" in text
|
|
||||||
assert "3.14159" in text
|
|
||||||
assert "-17" in text
|
|
||||||
|
|||||||
@@ -24,26 +24,15 @@ 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.
|
Adding a listener to an event should add it to the list of listeners. It
|
||||||
It should only be added once.
|
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
|
assert len(event._listeners) == 1 # Only one item added.
|
||||||
assert listener in event._listeners
|
assert listener in event._listeners # The item is the expected listener.
|
||||||
|
|
||||||
|
|
||||||
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():
|
||||||
@@ -56,25 +45,12 @@ 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
|
assert len(event._listeners) == 2 # Two listeners added.
|
||||||
assert listener1 in event._listeners
|
assert listener1 in event._listeners # The first listener is in the list.
|
||||||
assert listener2 in event._listeners
|
assert listener2 in event._listeners # The second listener is in the list.
|
||||||
event.remove_listener(listener1)
|
event.remove_listener(listener1)
|
||||||
assert len(event._listeners) == 1
|
assert len(event._listeners) == 1 # Only one item remains.
|
||||||
assert listener2 in event._listeners
|
assert listener2 in event._listeners # The second listener is in the list.
|
||||||
|
|
||||||
|
|
||||||
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():
|
||||||
@@ -86,15 +62,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
|
assert len(event._listeners) == 2 # Two listeners added.
|
||||||
event.remove_listener()
|
event.remove_listener()
|
||||||
assert len(event._listeners) == 0
|
assert len(event._listeners) == 0 # No listeners remain.
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
result.
|
arguments.
|
||||||
"""
|
"""
|
||||||
event = Event()
|
event = Event()
|
||||||
counter = 0
|
counter = 0
|
||||||
@@ -105,45 +81,15 @@ def test_event_trigger():
|
|||||||
assert x == "ok"
|
assert x == "ok"
|
||||||
|
|
||||||
event.add_listener(listener)
|
event.add_listener(listener)
|
||||||
assert counter == 0
|
assert counter == 0 # The listener has not been triggered yet.
|
||||||
event.trigger("ok")
|
event.trigger("ok")
|
||||||
assert counter == 1
|
assert counter == 1 # The listener has been triggered with the expected result.
|
||||||
|
|
||||||
|
|
||||||
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 result.
|
with the provided arguments.
|
||||||
"""
|
"""
|
||||||
call_flag = asyncio.Event()
|
call_flag = asyncio.Event()
|
||||||
event = Event()
|
event = Event()
|
||||||
@@ -156,119 +102,16 @@ async def test_event_trigger_with_awaitable():
|
|||||||
call_flag.set()
|
call_flag.set()
|
||||||
|
|
||||||
event.add_listener(listener)
|
event.add_listener(listener)
|
||||||
assert counter == 0
|
assert counter == 0 # The listener has not been triggered yet.
|
||||||
event.trigger("ok")
|
event.trigger("ok")
|
||||||
await call_flag.wait()
|
await call_flag.wait()
|
||||||
assert counter == 1
|
assert counter == 1 # The listener has been triggered with the expected result.
|
||||||
|
|
||||||
|
|
||||||
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, it should be
|
When the decorated function takes a single parameter,
|
||||||
passed the event object.
|
it should be 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()
|
||||||
@@ -290,8 +133,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, it should be called
|
When the decorated function takes no parameters (not including 'self'),
|
||||||
without the event object.
|
it should be called 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()
|
||||||
@@ -300,7 +143,7 @@ async def test_when_decorator_without_event():
|
|||||||
called = False
|
called = False
|
||||||
call_flag = asyncio.Event()
|
call_flag = asyncio.Event()
|
||||||
|
|
||||||
@when("click", selector="#foo_id")
|
@web.when("click", selector="#foo_id")
|
||||||
def foo():
|
def foo():
|
||||||
nonlocal called
|
nonlocal called
|
||||||
called = True
|
called = True
|
||||||
@@ -313,8 +156,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, it should be
|
When the decorated function takes a single parameter,
|
||||||
passed the event object. Async version.
|
it should be 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()
|
||||||
@@ -336,8 +179,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, it should be called
|
When the decorated function takes no parameters (not including 'self'),
|
||||||
without the event object. Async version.
|
it should be called 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()
|
||||||
@@ -346,7 +189,7 @@ async def test_when_decorator_without_event_as_async_handler():
|
|||||||
called = False
|
called = False
|
||||||
call_flag = asyncio.Event()
|
call_flag = asyncio.Event()
|
||||||
|
|
||||||
@when("click", selector="#foo_id")
|
@web.when("click", selector="#foo_id")
|
||||||
async def foo():
|
async def foo():
|
||||||
nonlocal called
|
nonlocal called
|
||||||
called = True
|
called = True
|
||||||
@@ -359,7 +202,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 independently.
|
When decorating a function twice, both should function
|
||||||
"""
|
"""
|
||||||
btn = web.button("foo_button", id="foo_id")
|
btn = web.button("foo_button", id="foo_id")
|
||||||
container = get_container()
|
container = get_container()
|
||||||
@@ -392,7 +235,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",
|
||||||
@@ -440,8 +283,7 @@ 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
|
When the selector parameter of @when is invalid, it should raise an error.
|
||||||
error.
|
|
||||||
"""
|
"""
|
||||||
if upytest.is_micropython:
|
if upytest.is_micropython:
|
||||||
from jsffi import JsException
|
from jsffi import JsException
|
||||||
@@ -456,320 +298,63 @@ 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
|
When the @when decorator is used on a function to handle an Event instance,
|
||||||
instance, the function should be called when the Event object is
|
the function should be called when the Event object is triggered.
|
||||||
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
|
A function that should be called when the whenable object is triggered.
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
async def test_when_with_list_of_events():
|
def test_when_called_with_an_event_and_handler():
|
||||||
"""
|
"""
|
||||||
The @when decorator should handle a list of Event objects.
|
The when function should be able to be called with an Event object,
|
||||||
|
and a handler function.
|
||||||
"""
|
"""
|
||||||
event1 = Event()
|
|
||||||
event2 = Event()
|
|
||||||
counter = 0
|
|
||||||
|
|
||||||
@when([event1, event2])
|
|
||||||
def handler(result):
|
|
||||||
nonlocal counter
|
|
||||||
counter += 1
|
|
||||||
|
|
||||||
assert counter == 0
|
|
||||||
event1.trigger("test1")
|
|
||||||
assert counter == 1
|
|
||||||
event2.trigger("test2")
|
|
||||||
assert counter == 2
|
|
||||||
|
|
||||||
|
|
||||||
async def test_when_with_async_event_handler():
|
|
||||||
"""
|
|
||||||
Async handlers should work with custom Event objects.
|
|
||||||
"""
|
|
||||||
event = Event()
|
|
||||||
call_flag = asyncio.Event()
|
|
||||||
counter = 0
|
|
||||||
|
|
||||||
@when(event)
|
|
||||||
async def handler(result):
|
|
||||||
nonlocal counter
|
|
||||||
counter += 1
|
|
||||||
assert result == "async test"
|
|
||||||
call_flag.set()
|
|
||||||
|
|
||||||
assert counter == 0
|
|
||||||
event.trigger("async test")
|
|
||||||
await call_flag.wait()
|
|
||||||
assert counter == 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_when_with_element_selector():
|
|
||||||
"""
|
|
||||||
The @when decorator should accept an Element object as selector.
|
|
||||||
"""
|
|
||||||
btn = web.button("test", id="elem_selector_test")
|
|
||||||
container = get_container()
|
|
||||||
container.append(btn)
|
|
||||||
|
|
||||||
called = False
|
|
||||||
call_flag = asyncio.Event()
|
|
||||||
|
|
||||||
@when("click", btn)
|
|
||||||
def handler(event):
|
|
||||||
nonlocal called
|
|
||||||
called = True
|
|
||||||
call_flag.set()
|
|
||||||
|
|
||||||
btn.click()
|
|
||||||
await call_flag.wait()
|
|
||||||
assert called
|
|
||||||
|
|
||||||
|
|
||||||
async def test_when_with_element_collection_selector():
|
|
||||||
"""
|
|
||||||
The @when decorator should accept an ElementCollection as selector.
|
|
||||||
"""
|
|
||||||
btn1 = web.button("btn1", id="col_test_1", classes=["test-class"])
|
|
||||||
btn2 = web.button("btn2", id="col_test_2", classes=["test-class"])
|
|
||||||
container = get_container()
|
|
||||||
container.append(btn1)
|
|
||||||
container.append(btn2)
|
|
||||||
|
|
||||||
collection = web.page.find(".test-class")
|
|
||||||
counter = 0
|
|
||||||
call_flag = asyncio.Event()
|
|
||||||
|
|
||||||
@when("click", collection)
|
|
||||||
def handler(event):
|
|
||||||
nonlocal counter
|
|
||||||
counter += 1
|
|
||||||
if counter == 2:
|
|
||||||
call_flag.set()
|
|
||||||
|
|
||||||
btn1.click()
|
|
||||||
btn2.click()
|
|
||||||
await call_flag.wait()
|
|
||||||
assert counter == 2
|
|
||||||
|
|
||||||
|
|
||||||
async def test_when_with_list_of_elements():
|
|
||||||
"""
|
|
||||||
The @when decorator should accept a list of DOM elements as selector.
|
|
||||||
"""
|
|
||||||
btn1 = web.button("btn1", id="list_test_1")
|
|
||||||
btn2 = web.button("btn2", id="list_test_2")
|
|
||||||
container = get_container()
|
|
||||||
container.append(btn1)
|
|
||||||
container.append(btn2)
|
|
||||||
|
|
||||||
elements = [btn1._dom_element, btn2._dom_element]
|
|
||||||
counter = 0
|
|
||||||
call_flag = asyncio.Event()
|
|
||||||
|
|
||||||
@when("click", elements)
|
|
||||||
def handler(event):
|
|
||||||
nonlocal counter
|
|
||||||
counter += 1
|
|
||||||
if counter == 2:
|
|
||||||
call_flag.set()
|
|
||||||
|
|
||||||
btn1.click()
|
|
||||||
btn2.click()
|
|
||||||
await call_flag.wait()
|
|
||||||
assert counter == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_when_decorator_returns_wrapper():
|
|
||||||
"""
|
|
||||||
The @when decorator should return the wrapped function.
|
|
||||||
"""
|
|
||||||
event = Event()
|
|
||||||
|
|
||||||
@when(event)
|
|
||||||
def handler(result):
|
|
||||||
return result
|
|
||||||
|
|
||||||
assert callable(handler)
|
|
||||||
|
|
||||||
|
|
||||||
def test_when_multiple_events_on_same_handler():
|
|
||||||
"""
|
|
||||||
Multiple @when decorators can be stacked on the same function.
|
|
||||||
"""
|
|
||||||
event1 = Event()
|
|
||||||
event2 = Event()
|
|
||||||
counter = 0
|
|
||||||
|
|
||||||
@when(event1)
|
|
||||||
@when(event2)
|
|
||||||
def handler(result):
|
|
||||||
nonlocal counter
|
|
||||||
counter += 1
|
|
||||||
|
|
||||||
assert counter == 0
|
|
||||||
event1.trigger("test")
|
|
||||||
assert counter == 1
|
|
||||||
event2.trigger("test")
|
|
||||||
assert counter == 2
|
|
||||||
|
|
||||||
|
|
||||||
async def test_when_on_different_callables():
|
|
||||||
"""
|
|
||||||
The @when decorator works with various callable types.
|
|
||||||
"""
|
|
||||||
results = []
|
|
||||||
|
|
||||||
def func(x):
|
|
||||||
results.append("func")
|
|
||||||
|
|
||||||
async def a_func(x):
|
|
||||||
results.append("a_func")
|
|
||||||
|
|
||||||
def make_inner_func():
|
|
||||||
def inner_func(x):
|
|
||||||
results.append("inner_func")
|
|
||||||
|
|
||||||
return inner_func
|
|
||||||
|
|
||||||
def make_inner_a_func():
|
|
||||||
async def inner_a_func(x):
|
|
||||||
results.append("inner_a_func")
|
|
||||||
|
|
||||||
return inner_a_func
|
|
||||||
|
|
||||||
def make_closure():
|
|
||||||
a = 1
|
|
||||||
|
|
||||||
def closure_func(x):
|
|
||||||
results.append(f"closure_func:{a}")
|
|
||||||
|
|
||||||
return closure_func
|
|
||||||
|
|
||||||
def make_a_closure():
|
|
||||||
a = 1
|
|
||||||
|
|
||||||
async def closure_a_func(x):
|
|
||||||
results.append(f"closure_a_func:{a}")
|
|
||||||
|
|
||||||
return closure_a_func
|
|
||||||
|
|
||||||
inner_func = make_inner_func()
|
|
||||||
inner_a_func = make_inner_a_func()
|
|
||||||
cl_func = make_closure()
|
|
||||||
cl_a_func = make_a_closure()
|
|
||||||
|
|
||||||
whenable = Event()
|
whenable = Event()
|
||||||
|
counter = 0
|
||||||
|
|
||||||
# Each of these should work with the @when decorator.
|
|
||||||
when(whenable)(func)
|
|
||||||
when(whenable)(a_func)
|
|
||||||
when(whenable)(inner_func)
|
|
||||||
when(whenable)(inner_a_func)
|
|
||||||
when(whenable)(cl_func)
|
|
||||||
when(whenable)(cl_a_func)
|
|
||||||
|
|
||||||
# Verify no handlers have been called yet.
|
|
||||||
assert len(results) == 0
|
|
||||||
|
|
||||||
# Trigger the event.
|
|
||||||
whenable.trigger("test")
|
|
||||||
|
|
||||||
# Wait for async handlers to complete.
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
|
||||||
# Verify all handlers were called.
|
|
||||||
assert len(results) == 6
|
|
||||||
assert "func" in results
|
|
||||||
assert "a_func" in results
|
|
||||||
assert "inner_func" in results
|
|
||||||
assert "inner_a_func" in results
|
|
||||||
assert "closure_func:1" in results
|
|
||||||
assert "closure_a_func:1" in results
|
|
||||||
|
|
||||||
|
|
||||||
async def test_when_dom_event_with_options():
|
|
||||||
"""
|
|
||||||
Options should be passed to addEventListener for DOM events.
|
|
||||||
"""
|
|
||||||
click_count = 0
|
|
||||||
call_flag = asyncio.Event()
|
|
||||||
|
|
||||||
@when("click", "#button-for-event-testing", once=True)
|
|
||||||
def handle_click(event):
|
|
||||||
nonlocal click_count
|
|
||||||
click_count += 1
|
|
||||||
call_flag.set()
|
|
||||||
|
|
||||||
btn = web.page["#button-for-event-testing"]
|
|
||||||
btn.click()
|
|
||||||
await call_flag.wait()
|
|
||||||
assert click_count == 1
|
|
||||||
|
|
||||||
# Click again - should not increment due to once=True.
|
|
||||||
btn.click()
|
|
||||||
# Bit of a bodge - a brief wait to ensure no handler fires.
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
assert click_count == 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_when_custom_event_options_ignored():
|
|
||||||
"""
|
|
||||||
Options should be silently ignored for custom Event objects.
|
|
||||||
"""
|
|
||||||
my_event = Event()
|
|
||||||
trigger_count = 0
|
|
||||||
call_flag = asyncio.Event()
|
|
||||||
|
|
||||||
@when(my_event, once=True)
|
|
||||||
def handler(result):
|
def handler(result):
|
||||||
nonlocal trigger_count
|
"""
|
||||||
trigger_count += 1
|
A function that should be called when the whenable object is triggered.
|
||||||
if trigger_count == 2:
|
|
||||||
call_flag.set()
|
|
||||||
|
|
||||||
# Should trigger multiple times despite once=True being ignored.
|
The result generated by the whenable object should be passed to the
|
||||||
my_event.trigger("first")
|
function.
|
||||||
my_event.trigger("second")
|
"""
|
||||||
await call_flag.wait()
|
nonlocal counter
|
||||||
assert trigger_count == 2
|
counter += 1
|
||||||
|
assert result == "ok"
|
||||||
|
|
||||||
|
# When as a function.
|
||||||
|
when(whenable, handler)
|
||||||
|
|
||||||
|
# The function should not be called until the whenable object is triggered.
|
||||||
|
assert counter == 0
|
||||||
|
# Trigger the whenable object.
|
||||||
|
whenable.trigger("ok")
|
||||||
|
# The function should have been called when the whenable object was
|
||||||
|
# triggered.
|
||||||
|
assert counter == 1
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Tests for the fetch function and response handling.
|
Ensure the pyscript.test function behaves as expected.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
from pyscript import fetch
|
from pyscript import fetch
|
||||||
|
|
||||||
|
|
||||||
@@ -19,17 +18,6 @@ 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.
|
||||||
@@ -43,17 +31,6 @@ 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.
|
||||||
@@ -61,22 +38,12 @@ 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.
|
||||||
@@ -91,217 +58,26 @@ async def test_fetch_array_buffer():
|
|||||||
assert b"1" in bytes_
|
assert b"1" in bytes_
|
||||||
|
|
||||||
|
|
||||||
async def test_fetch_array_buffer_direct():
|
async def test_fetch_ok():
|
||||||
"""
|
"""
|
||||||
The fetch function should support direct method chaining for arrayBuffer.
|
The fetch function should return a response with ok set to True for an
|
||||||
"""
|
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
|
||||||
blob = await response.blob()
|
assert response.status == 200
|
||||||
assert blob.size > 0
|
data = await response.json()
|
||||||
assert blob.type in ["application/json", "application/json; charset=utf-8"]
|
assert data["userId"] == 1
|
||||||
|
assert data["id"] == 1
|
||||||
|
assert data["title"] == "delectus aut autem"
|
||||||
|
assert data["completed"] is False
|
||||||
|
|
||||||
|
|
||||||
async def test_fetch_blob_direct():
|
async def test_fetch_not_ok():
|
||||||
"""
|
"""
|
||||||
The fetch function should support direct method chaining for blob.
|
The fetch function should return a response with ok set to False for a
|
||||||
"""
|
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
|
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
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
|
||||||
@@ -41,85 +38,3 @@ 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
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
"""
|
|
||||||
**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)
|
|
||||||
@@ -32,7 +32,7 @@ def test_js_module_is_available_on_worker():
|
|||||||
|
|
||||||
|
|
||||||
@upytest.skip("Worker only.", skip_when=not RUNNING_IN_WORKER)
|
@upytest.skip("Worker only.", skip_when=not RUNNING_IN_WORKER)
|
||||||
def test_js_module_is_available_on_greeting_worker():
|
def test_js_module_is_available_on_worker():
|
||||||
"""
|
"""
|
||||||
The "hello" function in the example_js_worker_module.js file is available
|
The "hello" function in the example_js_worker_module.js file is available
|
||||||
via the js_modules object while running in a worker.
|
via the js_modules object while running in a worker.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""
|
""""
|
||||||
Tests for the PyScript media module.
|
Tests for the PyScript media module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -7,170 +7,76 @@ import upytest
|
|||||||
from pyscript import media
|
from pyscript import media
|
||||||
|
|
||||||
|
|
||||||
async def test_list_devices_returns_list():
|
async def test_device_enumeration():
|
||||||
"""
|
"""Test enumerating media devices."""
|
||||||
The list_devices function should return a list of Device objects.
|
devices = await media.list_devices()
|
||||||
"""
|
assert isinstance(devices, list), "list_devices should return a list"
|
||||||
try:
|
|
||||||
devices = await media.list_devices()
|
# If devices are found, verify they have the expected functionality
|
||||||
assert isinstance(devices, list)
|
if devices:
|
||||||
except Exception:
|
device = devices[0]
|
||||||
# Permission denied or no devices available - test passes.
|
|
||||||
pass
|
# Test real device properties exist (but don't assert on their values)
|
||||||
|
# Browser security might restrict actual values until permissions are granted
|
||||||
|
assert hasattr(device, "id"), "Device should have id property"
|
||||||
async def test_device_properties():
|
assert hasattr(device, "kind"), "Device should have kind property"
|
||||||
"""
|
assert device.kind in [
|
||||||
Device objects should have expected properties.
|
"videoinput",
|
||||||
"""
|
"audioinput",
|
||||||
try:
|
"audiooutput",
|
||||||
devices = await media.list_devices()
|
], f"Device should have a valid kind, got: {device.kind}"
|
||||||
|
|
||||||
if devices:
|
# Verify dictionary access works with actual device
|
||||||
device = devices[0]
|
assert (
|
||||||
|
device["id"] == device.id
|
||||||
# Test all properties exist.
|
), "Dictionary access should match property access"
|
||||||
assert hasattr(device, "id")
|
assert (
|
||||||
assert hasattr(device, "kind")
|
device["kind"] == device.kind
|
||||||
assert hasattr(device, "label")
|
), "Dictionary access should match property access"
|
||||||
assert hasattr(device, "group")
|
|
||||||
|
|
||||||
# Test kind has valid value.
|
@upytest.skip("Waiting on a bug-fix in MicroPython, for this test to work.", skip_when=upytest.is_micropython)
|
||||||
assert device.kind in [
|
async def test_video_stream_acquisition():
|
||||||
"videoinput",
|
"""Test video stream."""
|
||||||
"audioinput",
|
|
||||||
"audiooutput",
|
|
||||||
]
|
|
||||||
except Exception:
|
|
||||||
# Permission denied or no devices available - test passes.
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def test_device_dict_access():
|
|
||||||
"""
|
|
||||||
Device objects should support dictionary-style access for JavaScript
|
|
||||||
interop.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
devices = await media.list_devices()
|
|
||||||
|
|
||||||
if devices:
|
|
||||||
device = devices[0]
|
|
||||||
|
|
||||||
# Dictionary access should match property access.
|
|
||||||
assert device["id"] == device.id
|
|
||||||
assert device["kind"] == device.kind
|
|
||||||
assert device["label"] == device.label
|
|
||||||
assert device["group"] == device.group
|
|
||||||
except Exception:
|
|
||||||
# Permission denied or no devices available - test passes.
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def test_request_stream_video_only():
|
|
||||||
"""
|
|
||||||
The request_stream method should return a stream for video only.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
stream = await media.Device.request_stream(video=True)
|
|
||||||
assert hasattr(stream, "active")
|
|
||||||
except Exception:
|
|
||||||
# Permission denied or no devices available - test passes.
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def test_request_stream_audio_only():
|
|
||||||
"""
|
|
||||||
The request_stream method should return a stream for audio only.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
stream = await media.Device.request_stream(audio=True, video=False)
|
|
||||||
assert hasattr(stream, "active")
|
|
||||||
except Exception:
|
|
||||||
# Permission denied or no devices available - test passes.
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def test_request_stream_audio_and_video():
|
|
||||||
"""
|
|
||||||
The request_stream method should return a stream for both audio and video.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
stream = await media.Device.request_stream(audio=True, video=True)
|
|
||||||
assert hasattr(stream, "active")
|
|
||||||
except Exception:
|
|
||||||
# Permission denied or no devices available - test passes.
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def test_request_stream_with_constraints():
|
|
||||||
"""
|
|
||||||
The request_stream method should accept video constraints as a dict.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
constraints = {"width": 640, "height": 480}
|
|
||||||
stream = await media.Device.request_stream(video=constraints)
|
|
||||||
assert hasattr(stream, "active")
|
|
||||||
except Exception:
|
|
||||||
# Permission denied or no devices available - test passes.
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def test_load_backwards_compatibility():
|
|
||||||
"""
|
|
||||||
The deprecated load method should still work for backwards compatibility.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
|
# Load a video stream
|
||||||
stream = await media.Device.load(video=True)
|
stream = await media.Device.load(video=True)
|
||||||
assert hasattr(stream, "active")
|
|
||||||
except Exception:
|
# Verify we get a real stream with expected properties
|
||||||
# Permission denied or no devices available - test passes.
|
assert hasattr(stream, "active"), "Stream should have active property"
|
||||||
pass
|
|
||||||
|
# Check for video tracks, but don't fail if permissions aren't granted
|
||||||
|
if stream._dom_element and hasattr(stream._dom_element, "getVideoTracks"):
|
||||||
|
tracks = stream._dom_element.getVideoTracks()
|
||||||
|
if tracks.length > 0:
|
||||||
|
assert True, "Video stream has video tracks"
|
||||||
|
except Exception as e:
|
||||||
|
# If the browser blocks access, the test should still pass
|
||||||
|
# This is because we're testing the API works, not that permissions are granted
|
||||||
|
assert (
|
||||||
|
True
|
||||||
|
), f"Stream acquisition attempted but may require permissions: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
@upytest.skip(
|
@upytest.skip("Waiting on a bug-fix in MicroPython, for this test to work.", skip_when=upytest.is_micropython)
|
||||||
"Blocks on Pyodide due to permission dialog.",
|
async def test_custom_video_constraints():
|
||||||
skip_when=not upytest.is_micropython,
|
"""Test loading video with custom constraints."""
|
||||||
)
|
|
||||||
async def test_device_get_stream():
|
|
||||||
"""
|
|
||||||
The get_stream instance method should return a stream from a specific
|
|
||||||
device.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
devices = await media.list_devices()
|
# Define custom constraints
|
||||||
|
constraints = {"width": 640, "height": 480}
|
||||||
|
|
||||||
# Find a video input device to test with.
|
# Load stream with custom constraints
|
||||||
video_devices = [d for d in devices if d.kind == "videoinput"]
|
stream = await media.Device.load(video=constraints)
|
||||||
|
|
||||||
if video_devices:
|
# Basic stream property check
|
||||||
stream = await video_devices[0].get_stream()
|
assert hasattr(stream, "active"), "Stream should have active property"
|
||||||
assert hasattr(stream, "active")
|
|
||||||
except Exception:
|
|
||||||
# Permission denied or no devices available - test passes.
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
# Check for tracks only if we have access
|
||||||
async def test_device_filtering_by_kind():
|
if stream._dom_element and hasattr(stream._dom_element, "getVideoTracks"):
|
||||||
"""
|
tracks = stream._dom_element.getVideoTracks()
|
||||||
Devices should be filterable by their kind property.
|
if tracks.length > 0 and hasattr(tracks[0], "getSettings"):
|
||||||
"""
|
# Settings verification is optional - browsers may handle constraints differently
|
||||||
try:
|
pass
|
||||||
devices = await media.list_devices()
|
except Exception as e:
|
||||||
|
# If the browser blocks access, test that the API structure works
|
||||||
video_inputs = [d for d in devices if d.kind == "videoinput"]
|
assert True, f"Custom constraint test attempted: {str(e)}"
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Tests for the pyscript.storage module.
|
Ensure the pyscript.storage object behaves as a Python dict.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pyscript import Storage, storage
|
from pyscript import Storage, storage
|
||||||
@@ -8,9 +8,6 @@ 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")
|
||||||
@@ -19,9 +16,6 @@ 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()
|
||||||
@@ -31,17 +25,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
|
||||||
@@ -92,233 +86,3 @@ 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)
|
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
"""
|
|
||||||
Tests for the pyscript.util module.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import upytest
|
import upytest
|
||||||
import js
|
import js
|
||||||
from pyscript import util
|
from pyscript import util
|
||||||
@@ -9,200 +5,44 @@ from pyscript import util
|
|||||||
|
|
||||||
def test_as_bytearray():
|
def test_as_bytearray():
|
||||||
"""
|
"""
|
||||||
The as_bytearray function should convert a JavaScript ArrayBuffer to a
|
Test the as_bytearray function correctly converts a JavaScript ArrayBuffer
|
||||||
Python bytearray.
|
to a Python bytearray.
|
||||||
"""
|
"""
|
||||||
msg = b"Hello, world!"
|
msg = b"Hello, world!"
|
||||||
buffer = js.ArrayBuffer.new(len(msg))
|
buffer = js.ArrayBuffer.new(len(msg))
|
||||||
ui8a = js.Uint8Array.new(buffer)
|
ui8a = js.Uint8Array.new(buffer)
|
||||||
for i, b in enumerate(msg):
|
for b in msg:
|
||||||
ui8a[i] = b
|
ui8a[i] = b
|
||||||
ba = util.as_bytearray(buffer)
|
ba = util.as_bytearray(buffer)
|
||||||
assert isinstance(ba, bytearray)
|
assert isinstance(ba, bytearray)
|
||||||
assert ba == msg
|
assert ba == msg
|
||||||
|
|
||||||
|
|
||||||
def test_as_bytearray_empty():
|
def test_not_supported():
|
||||||
"""
|
"""
|
||||||
The as_bytearray function should handle empty ArrayBuffers.
|
Test the NotSupported class raises an exception when trying to access
|
||||||
"""
|
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.some_attr
|
ns.test
|
||||||
assert str(e.exception) == "This is not supported."
|
assert str(e.exception) == "This is not supported.", str(e.exception)
|
||||||
|
|
||||||
|
|
||||||
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.some_attr = 1
|
ns.test = 1
|
||||||
assert str(e.exception) == "This is not supported."
|
assert str(e.exception) == "This is not supported.", str(e.exception)
|
||||||
|
|
||||||
|
|
||||||
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."
|
assert str(e.exception) == "This is not supported.", str(e.exception)
|
||||||
|
|
||||||
|
|
||||||
def test_not_supported_call_with_args():
|
def test_is_awaitable():
|
||||||
"""
|
"""
|
||||||
The NotSupported class should raise TypeError when called with arguments.
|
Test the is_awaitable function correctly identifies an asynchronous
|
||||||
"""
|
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():
|
||||||
pass
|
yield
|
||||||
|
|
||||||
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)
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user