diff --git a/.prettierignore b/.prettierignore index 32b310b5..8251e101 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ ISSUE_TEMPLATE *.min.* package-lock.json +bridge/ diff --git a/bridge/README.md b/bridge/README.md new file mode 100644 index 00000000..f345f1e3 --- /dev/null +++ b/bridge/README.md @@ -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`. diff --git a/bridge/index.js b/bridge/index.js new file mode 100644 index 00000000..2c4776e4 --- /dev/null +++ b/bridge/index.js @@ -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} 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); +}; diff --git a/bridge/package.json b/bridge/package.json new file mode 100644 index 00000000..1bf49234 --- /dev/null +++ b/bridge/package.json @@ -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" +} diff --git a/bridge/test/index.html b/bridge/test/index.html new file mode 100644 index 00000000..7ee05f2d --- /dev/null +++ b/bridge/test/index.html @@ -0,0 +1,33 @@ + + + + + + PyScript Bridge + + + + + + + + diff --git a/bridge/test/remote/index.html b/bridge/test/remote/index.html new file mode 100644 index 00000000..61976be5 --- /dev/null +++ b/bridge/test/remote/index.html @@ -0,0 +1,40 @@ + + + + + + PyScript Bridge + + + + + + + diff --git a/bridge/test/sys_version.py b/bridge/test/sys_version.py new file mode 100644 index 00000000..07b9a94b --- /dev/null +++ b/bridge/test/sys_version.py @@ -0,0 +1,5 @@ +import sys + + +def version(): + return sys.version diff --git a/bridge/test/test.js b/bridge/test/test.js new file mode 100644 index 00000000..4c896e04 --- /dev/null +++ b/bridge/test/test.js @@ -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, +}); diff --git a/bridge/test/test.py b/bridge/test/test.py new file mode 100644 index 00000000..7fd13851 --- /dev/null +++ b/bridge/test/test.py @@ -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()