mirror of
https://github.com/pyscript/pyscript.git
synced 2025-12-19 10:17:23 -05:00
The PyScript Bridge Helper (#2353)
* The PyScript Bridge Helper * added importmap to test latest versions with ease
This commit is contained in:
committed by
GitHub
parent
d68260c0c7
commit
7336ae545e
@@ -1,3 +1,4 @@
|
|||||||
ISSUE_TEMPLATE
|
ISSUE_TEMPLATE
|
||||||
*.min.*
|
*.min.*
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
bridge/
|
||||||
|
|||||||
57
bridge/README.md
Normal file
57
bridge/README.md
Normal 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
150
bridge/index.js
Normal 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
27
bridge/package.json
Normal 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
33
bridge/test/index.html
Normal 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>
|
||||||
40
bridge/test/remote/index.html
Normal file
40
bridge/test/remote/index.html
Normal 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>
|
||||||
5
bridge/test/sys_version.py
Normal file
5
bridge/test/sys_version.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def version():
|
||||||
|
return sys.version
|
||||||
17
bridge/test/test.js
Normal file
17
bridge/test/test.js
Normal 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
22
bridge/test/test.py
Normal 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()
|
||||||
Reference in New Issue
Block a user