Compare commits

...

6 Commits

Author SHA1 Message Date
Andrea Giammarchi
71ad1a40cb Update Polyscript with latest Micropython (#2357) 2025-07-04 09:34:12 +02:00
Andrea Giammarchi
e433275938 Readme update (#2356)
* Updated the README with latest PyScript version

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

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

---------

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

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

* Fix comment.
2025-06-18 15:01:33 +01:00
Nicholas Tollervey
14cc05fb80 Fix code example problem in the release HTML. (#2345) 2025-05-21 16:27:27 +01:00
25 changed files with 791 additions and 336 deletions

View File

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

View File

@@ -13,15 +13,15 @@ Using PyScript is as simple as:
<title>PyScript!</title>
<link
rel="stylesheet"
href="https://pyscript.net/snapshots/2024.9.2/core.css"
href="https://pyscript.net/releases/2025.7.2/core.css"
/>
<script
type="module"
src="https://pyscript.net/snapshots/2024.9.2/core.js"
src="https://pyscript.net/releases/2025.7.2/core.js"
></script>
</head>
<body>
<!-- Use MicroPython to evaluate some Python -->
<!-- type mpy (MicroPython) or py (Pyodide) to run some Python -->
<script type="mpy" terminal>
print("Hello, world!")
</script>

57
bridge/README.md Normal file
View File

@@ -0,0 +1,57 @@
# @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
* **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:
* `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`.

150
bridge/index.js Normal file
View File

@@ -0,0 +1,150 @@
/*! (c) PyScript Development Team */
const { stringify } = JSON;
const { create, entries } = Object;
/**
* 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,
} = {}) => {
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 = document.createElement('script');
script.type = type;
// 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);
// augment the code with the previously accessed fields at the end
script.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');
// 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();
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);
};

27
bridge/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "@pyscript/bridge",
"version": "0.1.0",
"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"
],
"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"
}

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

@@ -0,0 +1,33 @@
<!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>
<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>
<!-- for local testing purpose only-->
<script type="importmap">{"imports":{"https://esm.run/@pyscript/bridge":"../index.js"}}</script>
<script type="module">
const { ffi: { test_func, test_other, version } } = await import('./test.js');
console.time("⏱️ first invoke");
const result = await test_func("PyScript Bridge");
console.timeEnd("⏱️ first invoke");
document.body.append(
Object.assign(
document.createElement("h3"),
{ textContent: result },
),
document.createElement("hr"),
await version(),
);
console.time("⏱️ other invokes");
await test_other("🐍");
console.timeEnd("⏱️ other invokes");
</script>
</head>
</html>

View File

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

View File

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

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

@@ -0,0 +1,17 @@
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, {
env: searchParams.get("env"),
type: searchParams.get("type") || "mpy",
worker: searchParams.has("worker"),
config: searchParams.has("config") ?
({
files: {
"./sys_version.py": "./sys_version.py",
},
}) : undefined,
});

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

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

616
core/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@pyscript/core",
"version": "0.6.53",
"version": "0.6.63",
"type": "module",
"description": "PyScript",
"module": "./index.js",
@@ -67,10 +67,10 @@
"dependencies": {
"@ungap/with-resolvers": "^0.1.0",
"@webreflection/idb-map": "^0.3.2",
"@webreflection/utils": "^0.1.0",
"@webreflection/utils": "^0.1.1",
"add-promise-listener": "^0.1.3",
"basic-devtools": "^0.1.6",
"polyscript": "^0.17.20",
"polyscript": "^0.17.34",
"sticky-module": "^0.1.1",
"to-json-callback": "^0.1.1",
"type-checked-collections": "^0.1.7"
@@ -78,24 +78,24 @@
"devDependencies": {
"@codemirror/commands": "^6.8.1",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/language": "^6.11.0",
"@codemirror/language": "^6.11.2",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.8",
"@playwright/test": "^1.52.0",
"@rollup/plugin-commonjs": "^28.0.3",
"@codemirror/view": "^6.38.0",
"@playwright/test": "^1.53.2",
"@rollup/plugin-commonjs": "^28.0.6",
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-terser": "^0.4.4",
"@webreflection/toml-j0.4": "^1.1.4",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"bun": "^1.2.13",
"bun": "^1.2.17",
"chokidar": "^4.0.3",
"codedent": "^0.1.2",
"codemirror": "^6.0.1",
"eslint": "^9.27.0",
"codemirror": "^6.0.2",
"eslint": "^9.30.0",
"flatted": "^3.3.3",
"rollup": "^4.41.0",
"rollup": "^4.44.1",
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-string": "^3.0.0",
"static-handler": "^0.5.3",

View File

@@ -154,6 +154,9 @@ for (const [TYPE] of TYPES) {
return await Promise.all(toBeAwaited);
};
if (Number.isSafeInteger(parsed?.experimental_ffi_timeout))
globalThis.reflected_ffi_timeout = parsed?.experimental_ffi_timeout;
configs.set(TYPE, { config: parsed, configURL, plugins, error });
}

File diff suppressed because one or more lines are too long

View File

@@ -16,3 +16,27 @@ except:
create_proxy = _cp
to_js = _tjs
try:
from polyscript import ffi as _ffi
direct = _ffi.direct
gather = _ffi.gather
query = _ffi.query
def assign(source, *args):
for arg in args:
_ffi.assign(source, to_js(arg))
return source
except:
import js
_assign = js.Object.assign
direct = lambda source: source
def assign(source, *args):
for arg in args:
_assign(source, to_js(arg))
return source

View File

@@ -124,6 +124,11 @@ class Element:
# Element instance via `for_`).
if name.endswith("_"):
name = name[:-1] # noqa: FURB188 No str.removesuffix() in MicroPython.
if name == "for":
# The `for` attribute is a special case as it is a keyword in both
# Python and JavaScript.
# We need to get it from the underlying DOM element as `htmlFor`.
name = "htmlFor"
return getattr(self._dom_element, name)
def __setattr__(self, name, value):
@@ -142,6 +147,11 @@ class Element:
# Element instance via `for_`).
if name.endswith("_"):
name = name[:-1] # noqa: FURB188 No str.removesuffix() in MicroPython.
if name == "for":
# The `for` attribute is a special case as it is a keyword in both
# Python and JavaScript.
# We need to set it on the underlying DOM element as `htmlFor`.
name = "htmlFor"
if name.startswith("on_"):
# Ensure on-events are cached in the _on_events dict if the

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -59,6 +59,11 @@ test('MicroPython + configURL', async ({ page }) => {
await page.waitForSelector('html.main.worker');
});
test('MicroPython + Symbols', async ({ page }) => {
await page.goto('http://localhost:8080/tests/javascript/worker-symbols.html');
await page.waitForSelector('html.main.worker');
});
test('Pyodide + terminal on Main', async ({ page }) => {
await page.goto('http://localhost:8080/tests/javascript/py-terminal-main.html');
await page.waitForSelector('html.ok');

View File

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

View File

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

View File

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

View File

@@ -24,5 +24,6 @@
"./example_js_worker_module.js": "greeting_worker"
}
},
"packages": ["Pillow" ]
"packages": ["Pillow" ],
"experimental_ffi_timeout": 0
}

View File

@@ -871,7 +871,17 @@ class TestElements:
self._create_el_and_basic_asserts("kbd", "some text")
def test_label(self):
self._create_el_and_basic_asserts("label", "some text")
label_text = "Luke, I am your father"
label_for = "some-id"
# Let's create the element
el = web.label(label_text, for_=label_for)
# Let's check the element was configured correctly.
assert isinstance(el, web.label), "The new element should be a label."
assert el.textContent == label_text, "The label text should match."
assert el._dom_element.tagName == "LABEL"
assert el.for_ == label_for, "The label should have the correct for attribute."
# Ensure the label element is rendered with the correct "for" attribute
assert f'for="{label_for}"' in el.outerHTML, "The label should have the correct 'for' attribute in its HTML."
def test_legend(self):
self._create_el_and_basic_asserts("legend", "some text")

View File

@@ -137,12 +137,14 @@
&lt;body&gt;
&lt;h1&gt;Hello world!&lt;/h1&gt;
&lt;p&gt;These are the Python interpreters in PyScript _VERSION_:&lt;/p&gt;
&lt;script type=&quot;py&quot;&gt; &lt;!-- Pyodide --&gt;
&lt;script type=&quot;py&quot;&gt;
# Pyodide
from pyscript import display
import sys
display(sys.version)
&lt;/script&gt;
&lt;script type=&quot;mpy&quot;&gt; &lt;!-- MicroPython --&gt;
&lt;script type=&quot;mpy&quot;&gt;
# MicroPython
from pyscript import display
import sys
display(sys.version)