8000 The PyScript Bridge Helper (#2353) · pyscript/pyscript@7336ae5 · GitHub
[go: up one dir, main page]

Skip to content

Commit 7336ae5

Browse files
The PyScript Bridge Helper (#2353)
* The PyScript Bridge Helper * added importmap to test latest versions with ease
1 parent d68260c commit 7336ae5

File tree

9 files changed

+352
-0
lines changed

9 files changed

+352
-0
lines changed

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
ISSUE_TEMPLATE
22
*.min.*
33
package-lock.json
4+
bridge/

bridge/README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# @pyscript/bridge
2+
3+
Import Python utilities directly in JS
4+
5+
```js
6+
// main thread
7+
const { ffi: { func_a, func_b } } = await import('./test.js');
8+
9+
// test.js
10+
import bridge from 'https://esm.run/@pyscript/bridge';
11+
export const ffi = bridge(import.meta.url, { type: 'mpy', worker: false });
12+
13+
// test.py
14+
def func_a(value):
15+
print(f"hello {value}")
16+
17+
def func_b():
18+
import sys
19+
return sys.version
20+
```
21+
22+
### Options
23+
24+
* **type**: `py` by default to bootstrap *Pyodide*.
25+
* **worker**: `true` by default to bootstrap in a *Web Worker*.
26+
* **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.
27+
* **env**: to share the same environment across multiple modules loaded at different times.
28+
29+
30+
## Tests
31+
32+
Run `npx mini-coi .` within this folder to then reach out `http://localhost:8080/test/` that will show:
33+
34+
```
35+
PyScript Bridge
36+
------------------
37+
no config
38+
```
39+
40+
The [test.js](./test/test.js) files uses the following defaults:
41+
42+
* `type` as `"mpy"`
43+
* `worker` as `false`
44+
* `config` as `undefined`
45+
* `env` as `undefined`
46+
47+
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.
48+
49+
To recap: `http://localhost:8080/test/?type=py&worker&config` will show this instead:
50+
51+
```
52+
PyScript Bridge
53+
------------------
54+
3.12.7 (main, May 15 2025, 18:47:24) ...
55+
```
56+
57+
Please note when a *config* is used, the `worker` attribute is always `true`.

bridge/index.js

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*! (c) PyScript Development Team */
2+
3+
const { stringify } = JSON;
4+
const { create, entries } = Object;
5+
6+
/**
7+
* Transform a list of keys into a Python dictionary.
8+
* `['a', 'b']` => `{ "a": a, "b": b }`
9+
* @param {Iterable<string>} keys
10+
* @returns {string}
11+
*/
12+
const dictionary = keys => {
13+
const fields = [];
14+
for (const key of keys)
15+
fields.push(`${stringify(key)}: ${key}`);
16+
return `{ ${fields.join(',')} }`;
17+
};
18+
19+
/**
20+
* Resolve properly config files relative URLs.
21+
* @param {string|Object} config - The configuration to normalize.
22+
* @param {string} base - The base URL to resolve relative URLs against.
23+
* @returns {string} - The JSON serialized config.
24+
*/
25+
const normalize = async (config, base) => {
26+
if (typeof config === 'string') {
27+
base = config;
28+
config = await fetch(config).then(res => res.json());
29+
}
30+
if (typeof config.files === 'object') {
31+
const files = {};
32+
for (const [key, value] of entries(config.files)) {
33+
files[key.startsWith('{') ? key : new URL(key, base)] = value;
34+
}
35+
config.files = files;
36+
}
37+
return stringify(config);
38+
};
39+
40+
// this logic is based on a 3 levels cache ...
41+
const cache = new Map;
42+
43+
/**
44+
* Return a bridge to a Python module via a `.js` file that has a `.py` alter ego.
45+
* @param {string} url - The URL of the JS module that has a Python counterpart.
46+
* @param {Object} options - The options for the bridge.
47+
* @param {string} [options.type='py'] - The `py` or `mpy` interpreter type, `py` by default.
48+
* @param {boolean} [options.worker=true] - Whether to use a worker, `true` by default.
49+
* @param {string|Object} [options.config=null] - The configuration for the bridge, `null` by default.
50+
* @param {string} [options.env=null] - The optional shared environment to use.
51+
* @param {string} [options.serviceWorker=null] - The optional service worker to use as fallback.
52+
* @returns {Object} - The bridge to the Python module.
53+
*/
54+
export default (url, {
55+
type = 'py',
56+
worker = true,
57+
config = null,
58+
env = null,
59+
serviceWorker = null,
60+
} = {}) => {
61+
const { protocol, host, pathname } = new URL(url);
62+
const py = pathname.replace(/\.m?js(?:\/\+\w+)?$/, '.py');
63+
const file = `${protocol}//${host}${py}`;
64+
65+
// the first cache is about the desired file in the wild ...
66+
if (!cache.has(file)) {
67+
// the second cache is about all fields one needs to access out there
68+
const exports = new Map;
69+
let python;
70+
71+
cache.set(file, new Proxy(create(null), {
72+
get(_, field) {
73+
if (!exports.has(field)) {
74+
// create an async callback once and always return the same later on
75+
exports.set(field, async (...args) => {
76+
// the third cache is about reaching lazily the code only once
77+
// augmenting its content with exports once and drop it on done
78+
if (!python) {
79+
// do not await or multiple calls will fetch multiple times
80+
// just assign the fetch `Promise` once and return it
81+
python = fetch(file).then(async response => {
82+
const code = await response.text();
83+
// create a unique identifier for the Python context
84+
const identifier = pathname.replace(/[^a-zA-Z0-9_]/g, '');
85+
const name = `__pyscript_${identifier}${Date.now()}`;
86+
// create a Python dictionary with all accessed fields
87+
const detail = `{"detail":${dictionary(exports.keys())}}`;
88+
// create the arguments for the `dispatchEvent` call
89+
const eventArgs = `${stringify(name)},${name}to_ts(${detail})`;
90+
// bootstrap the script element type and its attributes
91+
const script = document.createElement('script');
92+
script.type = type;
93+
94+
// if config is provided it needs to be a worker to avoid
95+
// conflicting with main config on the main thread (just like always)
96+
script.toggleAttribute('worker', !!config || !!worker);
97+
if (config) {
98+
const attribute = await normalize(config, file);
99+
script.setAttribute('config', attribute);
100+
}
101+
102+
if (env) script.setAttribute('env', env);
103+
if (serviceWorker) script.setAttribute('service-worker', serviceWorker);
104+
105+
// augment the code with the previously accessed fields at the end
106+
script.textContent = [
107+
'\n', code, '\n',
108+
// this is to avoid local scope name clashing
109+
`from pyscript import window as ${name}`,
110+
`from pyscript.ffi import to_js as ${name}to_ts`,
111+
`${name}.dispatchEvent(${name}.CustomEvent.new(${eventArgs}))`,
112+
// remove these references even if non-clashing to keep
113+
// the local scope clean from undesired entries
114+
`del ${name}`,
115+
`del ${name}to_ts`,
116+
].join('\n');
117+
118+
// let PyScript resolve and execute this script
119+
document.body.appendChild(script);
120+
121+
// intercept once the unique event identifier with all exports
122+
globalThis.addEventListener(
123+
name,
124+
event => {
125+
resolve(event.detail);
126+
script.remove();
127+
},
128+
{ once: true }
129+
);
130+
131+
// return a promise that will resolve only once the event
132+
// has been emitted and the interpreter evaluated the code
133+
const { promise, resolve } = Promise.withResolvers();
134+
return promise;
135+
});
136+
}
137+
138+
// return the `Promise` that will after invoke the exported field
139+
return python.then(foreign => foreign[field](...args));
140+
});
141+
}
142+
143+
// return the lazily to be resolved once callback to invoke
144+
return exports.get(field);
145+
}
146+
}));
147+
}
148+
149+
return cache.get(file);
150+
};

bridge/package.json

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "@pyscript/bridge",
3+
"version": "0.1.0",
4+
"description": "A JS based way to use PyScript modules",
5+
"type": "module",
6+
"module": "./index.js",
7+
"unpkg": "./index.js",
8+
"jsdelivr": "./jsdelivr.js",
9+
"browser": "./index.js",
10+
"main": "./index.js",
11+
"keywords": [
12+
"PyScript",
13+
"JS",
14+
"Python",
15+
"bridge"
16+
],
17+
"author": "Anaconda Inc.",
18+
"license": "APACHE-2.0",
19+
"repository": {
20+
"type": "git",
21+
"url": "git+https://github.com/pyscript/pyscript.git"
22+
},
23+
"bugs": {
24+
"url": "https://github.com/pyscript/pyscript/issues"
25+
},
26+
"homepage": "https://github.com/pyscript/pyscript#readme"
27+
}

bridge/test/index.html

10000 Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width,initial-scale=1">
6+
<title>PyScript Bridge</title>
7+
<style>body { font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; }</style>
8+
<link rel="stylesheet" href="https://pyscript.net/releases/2025.5.1/core.css" />
9+
<script type="module" src="https://pyscript.net/releases/2025.5.1/core.js"></script>
10+
<!-- for local testing purpose only-->
11+
<script type="importmap">{"imports":{"https://esm.run/@pyscript/bridge":"../index.js"}}</script>
12+
<script type="module">
13+
const { ffi: { test_func, test_other, version } } = await import('./test.js');
14+
15+
console.time("⏱️ first invoke");
16+
const result = await test_func("PyScript Bridge");
17+
console.timeEnd("⏱️ first invoke");
18+
19+
document.body.append(
20+
Object.assign(
21+
document.createElement("h3"),
22+
{ textContent: result },
23+
),
24+
document.createElement("hr"),
25+
await version(),
26+
);
27+
28+
console.time("⏱️ other invokes");
29+
await test_other("🐍");
30+
console.timeEnd("⏱️ other invokes");
31+
</script>
32+
</head>
33+
</html>

bridge/test/remote/index.html

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width,initial-scale=1">
6+
<title>PyScript Bridge</title>
7+
<script type="importmap">
8+
{
9+
"imports": {
10+
"https://esm.run/@pyscript/bridge": "https://esm.run/@pyscript/bridge@latest",
11+
"https://esm.run/@pyscript/bridge/test/test.js": "https://esm.run/@pyscript/bridge@latest/test/test.js"
12+
}
13+
}
14+
</script>
15+
<style>body { font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; }</style>
16+
<link rel="stylesheet" href="https://pyscript.net/releases/2025.5.1/core.css" />
17+
<script type="module" src="https://pyscript.net/releases/2025.5.1/core.js"></script>
18+
<script type="module">
19+
const cdn_test = 'https://esm.run/@pyscript/bridge/test/test.js';
20+
const { ffi: { test_func, test_other, version } } = await import(cdn_test);
21+
22+
console.time("⏱️ first invoke");
23+
const result = await test_func("PyScript Bridge");
24+
console.timeEnd("⏱️ first invoke");
25+
26+
document.body.append(
27+
Object.assign(
28+
document.createElement("h3"),
29+
{ textContent: result },
30+
),
31+
document.createElement("hr"),
32+
await version(),
33+
);
34+
35+
console.time("⏱️ other invokes");
36+
await test_other("🐍");
37+
console.timeEnd("⏱️ other invokes");
38+
</script>
39+
</head>
40+
</html>

bridge/test/sys_version.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import sys
2+
3+
4+
def version():
5+
return sys.version

bridge/test/test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import bridge from "https://esm.run/@pyscript/bridge";
2+
3+
// for local testing purpose only
4+
const { searchParams } = new URL(location.href);
5+
6+
// the named (or default) export for test.py
7+
export const ffi = bridge(import.meta.url, {
8+
env: searchParams.get("env"),
9+
type: searchParams.get("type") || "mpy",
10+
worker: searchParams.has("worker"),
11+
config: searchParams.has("config") ?
12+
({
13+
files: {
14+
"./sys_version.py": "./sys_version.py",
15+
},
16+
}) : undefined,
17+
});

bridge/test/test.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from pyscript import config, RUNNING_IN_WORKER
2+
3+
type = config["type"]
4+
print(f"{type}-script", RUNNING_IN_WORKER and "worker" or "main")
5+
6+
7+
def test_func(message):
8+
print("Python", message)
9+
return message
10+
11+
12+
def test_other(message):
13+
print("Python", message)
14+
return message
15+
16+
17+
def version():
18+
try:
19+
from sys_version import version
20+
except ImportError:
21+
version = lambda: "no config"
22+
return version()

0 commit comments

Comments
 (0)
0