From 84e71c732003193c2d96b4448d74c31d449d5bbd Mon Sep 17 00:00:00 2001 From: "Nicholas H.Tollervey" Date: Thu, 27 Nov 2025 18:46:51 +0000 Subject: [PATCH 01/47] Revise display module. TODO: more comprehensive tests. Especially around mimebundles. --- core/src/stdlib/pyscript.js | 2 +- core/src/stdlib/pyscript/display.py | 321 +++++++++++++++++----------- requirements.txt | 2 +- 3 files changed, 200 insertions(+), 125 deletions(-) diff --git a/core/src/stdlib/pyscript.js b/core/src/stdlib/pyscript.js index fcac6257044..5e74d9389b8 100644 --- a/core/src/stdlib/pyscript.js +++ b/core/src/stdlib/pyscript.js @@ -2,7 +2,7 @@ export default { "pyscript": { "__init__.py": "from polyscript import lazy_py_modules as py_import\nfrom pyscript.magic_js import RUNNING_IN_WORKER,PyWorker,config,current_target,document,js_import,js_modules,sync,window\nfrom pyscript.display import HTML,display\nfrom pyscript.fetch import fetch\nfrom pyscript.storage import Storage,storage\nfrom pyscript.websocket import WebSocket\nfrom pyscript.events import when,Event\nif not RUNNING_IN_WORKER:from pyscript.workers import create_named_worker,workers", - "display.py": "_K='_repr_mimebundle_'\n_J='image/svg+xml'\n_I='application/json'\n_H='__repr__'\n_G='savefig'\n_F='text/html'\n_E='image/jpeg'\n_D='application/javascript'\n_C='utf-8'\n_B='text/plain'\n_A='image/png'\nimport base64,html,io,re\nfrom pyscript.magic_js import current_target,document,window\nfrom pyscript.ffi import is_none\n_MIME_METHODS={_G:_A,'_repr_javascript_':_D,'_repr_json_':_I,'_repr_latex':'text/latex','_repr_png_':_A,'_repr_jpeg_':_E,'_repr_pdf_':'application/pdf','_repr_svg_':_J,'_repr_markdown_':'text/markdown','_repr_html_':_F,_H:_B}\ndef _render_image(mime,value,meta):\n\tA=value\n\tif isinstance(A,bytes):A=base64.b64encode(A).decode(_C)\n\tB=re.compile('^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$')\n\tif len(A)>0 and not B.match(A):A=base64.b64encode(A.encode(_C)).decode(_C)\n\tC=f\"data:{mime};charset=utf-8;base64,{A}\";D=' '.join(['{k}=\"{v}\"'for(A,B)in meta.items()]);return f''\ndef _identity(value,meta):return value\n_MIME_RENDERERS={_B:html.escape,_F:_identity,_A:lambda value,meta:_render_image(_A,value,meta),_E:lambda value,meta:_render_image(_E,value,meta),_J:_identity,_I:_identity,_D:lambda value,meta:f\"") + container = await get_display_container() + assert "<script>" in container[0].innerHTML + assert " + + + +``` + +Dynamically creating named workers: + +```python +from pyscript import create_named_worker + + +# Create a worker from a Python file. +worker = await create_named_worker( + src="./background_tasks.py", + 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. + """ -# this solves an inconsistency between Pyodide and MicroPython -# @see https://github.com/pyscript/pyscript/issues/2106 -class _ReadOnlyProxy: 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): - 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. -workers = _ReadOnlyProxy() + ```python + worker = await workers.my_worker + ``` + """ + return js.Reflect.get(_polyscript_workers, name) -async def create_named_worker(src="", name="", config=None, type="py"): - from json import dumps +# Global workers proxy for accessing named workers. +workers = _ReadOnlyWorkersProxy() - if not src: - msg = "Named workers require src" - raise ValueError(msg) - if not name: - msg = "Named workers require a name" - raise ValueError(msg) +async def create_named_worker(src, name, config=None, type="py"): + """ + 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`). - s = _js.document.createElement("script") - s.type = type - s.src = src - _set(s, "worker") - _set(s, "name", name) + This function creates a new web worker by injecting a script tag into + the document. The worker will be accessible via the `workers` proxy once + it's ready. - if config: - _set(s, "config", (isinstance(config, str) and config) or dumps(config)) + It return a promise that resolves to the worker reference when ready. - _js.document.body.append(s) + ```python + from pyscript import create_named_worker + + + # 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" + ) + ``` + + **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 isinstance(config, str): + config_str = config + else: + 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] From 7d8d9a8c3273e2e69e8a50dcd1f7ba323ee043da Mon Sep 17 00:00:00 2001 From: "Nicholas H.Tollervey" Date: Thu, 4 Dec 2025 13:08:58 +0000 Subject: [PATCH 38/47] Added tests for workers.py module. Updated related test suite to account for the new named worker in the test HTML. --- core/tests/python/index.html | 2 + core/tests/python/settings_mpy.json | 11 +- core/tests/python/settings_py.json | 13 +-- .../tests/python/tests/test_current_target.py | 4 +- core/tests/python/tests/test_websocket.py | 2 +- core/tests/python/tests/test_workers.py | 105 ++++++++++++++++++ core/tests/python/worker_functions.py | 18 +++ 7 files changed, 137 insertions(+), 18 deletions(-) create mode 100644 core/tests/python/tests/test_workers.py create mode 100644 core/tests/python/worker_functions.py diff --git a/core/tests/python/index.html b/core/tests/python/index.html index f0ca795763a..8d2fd43fbe9 100644 --- a/core/tests/python/index.html +++ b/core/tests/python/index.html @@ -23,6 +23,8 @@
+ +

Test Read and Write

Content test_rr_div
diff --git a/core/tests/python/settings_mpy.json b/core/tests/python/settings_mpy.json index 853b67799d1..255f6f9cd87 100644 --- a/core/tests/python/settings_mpy.json +++ b/core/tests/python/settings_mpy.json @@ -16,14 +16,11 @@ "./tests/test_running_in_worker.py": "tests/test_running_in_worker.py", "./tests/test_web.py": "tests/test_web.py", "./tests/test_websocket.py": "tests/test_websocket.py", - "./tests/test_window.py": "tests/test_window.py" + "./tests/test_window.py": "tests/test_window.py", + "./tests/test_workers.py": "tests/test_workers.py" }, "js_modules": { - "main": { - "./example_js_module.js": "greeting" - }, - "worker": { - "./example_js_worker_module.js": "greeting_worker" - } + "main": {"./example_js_module.js": "greeting"}, + "worker": {"./example_js_worker_module.js": "greeting_worker"} } } diff --git a/core/tests/python/settings_py.json b/core/tests/python/settings_py.json index 707e8ff67a8..af3e03c6d8e 100644 --- a/core/tests/python/settings_py.json +++ b/core/tests/python/settings_py.json @@ -16,16 +16,13 @@ "./tests/test_running_in_worker.py": "tests/test_running_in_worker.py", "./tests/test_web.py": "tests/test_web.py", "./tests/test_websocket.py": "tests/test_websocket.py", - "./tests/test_window.py": "tests/test_window.py" + "./tests/test_window.py": "tests/test_window.py", + "./tests/test_workers.py": "tests/test_workers.py" }, "js_modules": { - "main": { - "./example_js_module.js": "greeting" - }, - "worker": { - "./example_js_worker_module.js": "greeting_worker" - } + "main": {"./example_js_module.js": "greeting"}, + "worker": {"./example_js_worker_module.js": "greeting_worker"} }, - "packages": ["Pillow" ], + "packages": ["Pillow"], "experimental_ffi_timeout": 0 } diff --git a/core/tests/python/tests/test_current_target.py b/core/tests/python/tests/test_current_target.py index cc4e24d2301..fa2daf61920 100644 --- a/core/tests/python/tests/test_current_target.py +++ b/core/tests/python/tests/test_current_target.py @@ -13,7 +13,7 @@ def test_current_target(): """ expected = "py-0" if is_micropython: - expected = "mpy-w0-target" if RUNNING_IN_WORKER else "mpy-0" + expected = "mpy-w1-target" if RUNNING_IN_WORKER else "mpy-0" elif RUNNING_IN_WORKER: - expected = "py-w0-target" + expected = "py-w1-target" assert current_target() == expected, f"Expected {expected} got {current_target()}" diff --git a/core/tests/python/tests/test_websocket.py b/core/tests/python/tests/test_websocket.py index 9b921d3dcd1..cd3fae8ac3a 100644 --- a/core/tests/python/tests/test_websocket.py +++ b/core/tests/python/tests/test_websocket.py @@ -10,7 +10,7 @@ # Websocket tests are disabled by default because they don't reliably work in # playwright based tests. Feel free to set this to False to enable them when -# running tests locally in an actualy browser (they all pass there). +# running tests locally in an actual browser (they all pass there). SKIP_WEBSOCKET_TESTS = True diff --git a/core/tests/python/tests/test_workers.py b/core/tests/python/tests/test_workers.py new file mode 100644 index 00000000000..988d4afbae1 --- /dev/null +++ b/core/tests/python/tests/test_workers.py @@ -0,0 +1,105 @@ +""" +Tests for the pyscript.workers module. + +Note: These tests can only run in the main thread since they test worker +creation and access. + +I've added the import of workers and create_named_worker inside each test +so that the test module can still be imported in a worker context without +errors. It also means the module is GC'd between tests, which is a good way +to ensure each test is independent given the global nature of the workers +proxy. +""" + +import upytest +from pyscript import RUNNING_IN_WORKER + + +@upytest.skip("Main thread only", skip_when=RUNNING_IN_WORKER) +async def test_workers_proxy_exists(): + """ + The workers proxy should be accessible and support both. + bracket and dot notation. + """ + from pyscript import workers + + assert workers is not None + # Defined in the HTML. + worker = await workers["testworker"] + assert worker is not None + worker = await workers.testworker + assert worker is not None + + +@upytest.skip("Main thread only", skip_when=RUNNING_IN_WORKER) +async def test_worker_exported_functions(): + """ + Functions exported from a worker should be callable. + """ + from pyscript import workers + + worker = await workers["testworker"] + # Test multiple exported functions. + add_result = await worker.add(10, 20) + multiply_result = await worker.multiply(4, 5) + greeting = await worker.get_message() + + assert add_result == 30 + assert multiply_result == 20 + assert greeting == "Hello from worker" + + +@upytest.skip("Main thread only", skip_when=RUNNING_IN_WORKER) +async def test_create_named_worker_basic(): + """ + Creating a named worker dynamically should work. + """ + from pyscript import create_named_worker, workers + + worker = await create_named_worker( + src="./worker_functions.py", name="dynamic-test-worker" + ) + + assert worker is not None + # Verify we can call its functions. + result = await worker.add(1, 2) + assert result == 3 + # Verify it's also accessible via the workers proxy. + same_worker = await workers["dynamic-test-worker"] + result2 = await same_worker.add(3, 4) + assert result2 == 7 + + +@upytest.skip("Main thread only", skip_when=RUNNING_IN_WORKER) +async def test_create_named_worker_with_config(): + """ + Creating a worker with configuration should work. + """ + from pyscript import create_named_worker + + # Create worker with a PyScript configuration dict. + worker = await create_named_worker( + src="./worker_functions.py", + name="configured-worker", + config={"packages_cache": "never"}, + ) + assert worker is not None + # Worker should still function normally. + result = await worker.multiply(6, 7) + assert result == 42 + + +@upytest.skip("Main thread only", skip_when=RUNNING_IN_WORKER) +async def test_create_named_worker_micropython(): + """ + Creating a MicroPython worker should work. + """ + from pyscript import create_named_worker + + worker = await create_named_worker( + src="./worker_functions.py", name="mpy-worker", type="mpy" + ) + assert worker is not None + # Verify functionality. + result = await worker.add(100, 200) + assert result == 300 diff --git a/core/tests/python/worker_functions.py b/core/tests/python/worker_functions.py new file mode 100644 index 00000000000..1f695c61239 --- /dev/null +++ b/core/tests/python/worker_functions.py @@ -0,0 +1,18 @@ +""" +Numpty test code to run in a worker for pyscript.workers module tests. +""" + + +def add(a, b): + return a + b + + +def multiply(a, b): + return a * b + + +def get_message(): + return "Hello from worker" + + +__export__ = ["add", "multiply", "get_message"] From 06773c02eb8933344fb86248a7b0466d3e548305 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 4 Dec 2025 13:09:12 +0000 Subject: [PATCH 39/47] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- core/src/stdlib/pyscript/workers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/stdlib/pyscript/workers.py b/core/src/stdlib/pyscript/workers.py index 06cc41af1fe..4a104baa7b9 100644 --- a/core/src/stdlib/pyscript/workers.py +++ b/core/src/stdlib/pyscript/workers.py @@ -71,7 +71,7 @@ class _ReadOnlyWorkersProxy: 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 From d1558b8ca506d5f5766a2133f2577bff48049dd7 Mon Sep 17 00:00:00 2001 From: "Nicholas H.Tollervey" Date: Thu, 4 Dec 2025 14:42:16 +0000 Subject: [PATCH 40/47] Refactor away remaining "is not None" not caught before. --- core/src/stdlib/pyscript.js | 6 +++--- core/src/stdlib/pyscript/display.py | 2 +- core/src/stdlib/pyscript/web.py | 12 ++++++------ core/src/stdlib/pyscript/websocket.py | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/core/src/stdlib/pyscript.js b/core/src/stdlib/pyscript.js index b415ad106ba..5c142f4d211 100644 --- a/core/src/stdlib/pyscript.js +++ b/core/src/stdlib/pyscript.js @@ -3,7 +3,7 @@ export default { "pyscript": { "__init__.py": "from polyscript import lazy_py_modules as py_import\nfrom pyscript.context import RUNNING_IN_WORKER,PyWorker,config,current_target,document,js_import,js_modules,sync,window\nfrom pyscript.display import HTML,display\nfrom pyscript.fetch import fetch\nfrom pyscript.storage import Storage,storage\nfrom pyscript.websocket import WebSocket\nfrom pyscript.events import when,Event\nif not RUNNING_IN_WORKER:from pyscript.workers import create_named_worker,workers", "context.py": "import json,sys,js\nfrom polyscript import config as _polyscript_config,js_modules\nfrom pyscript.util import NotSupported\nRUNNING_IN_WORKER=not hasattr(js,'document')\nconfig=json.loads(js.JSON.stringify(_polyscript_config))\nif isinstance(config,str):config={}\nif'MicroPython'in sys.version:config['type']='mpy'\nelse:config['type']='py'\nclass _JSModuleProxy:\n\tdef __init__(A,name):A.name=name\n\tdef __getattr__(B,field):\n\t\tA=field\n\t\tif not A.startswith('_'):return getattr(getattr(js_modules,B.name),A)\nfor module_name in js.Reflect.ownKeys(js_modules):sys.modules[f\"pyscript.js_modules.{module_name}\"]=_JSModuleProxy(module_name)\nsys.modules['pyscript.js_modules']=js_modules\nif RUNNING_IN_WORKER:\n\timport polyscript;PyWorker=NotSupported('pyscript.PyWorker','pyscript.PyWorker works only when running in the main thread')\n\ttry:window=polyscript.xworker.window;document=window.document;js.document=document;js_import=window.Function('return (...urls) => Promise.all(urls.map((url) => import(url)))')()\n\texcept: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);js_import=None\n\tsync=polyscript.xworker.sync\n\tdef current_target():return polyscript.target\nelse:\n\timport _pyscript;from _pyscript import PyWorker as _PyWorker,js_import;from pyscript.ffi import to_js\n\tdef PyWorker(url,**A):return _PyWorker(url,to_js(A))\n\twindow=js;document=js.document;sync=NotSupported('pyscript.sync','pyscript.sync works only when running in a worker')\n\tdef current_target():return _pyscript.target", - "display.py": "_I='__repr__'\n_H='savefig'\n_G='application/json'\n_F='image/svg+xml'\n_E='application/javascript'\n_D='image/jpeg'\n_C='text/html'\n_B='image/png'\n_A='text/plain'\nimport base64,html,io\nfrom collections import OrderedDict\nfrom pyscript.context import current_target,document,window\nfrom pyscript.ffi import is_none\ndef _render_image(mime,value,meta):\n\tA=value\n\tif isinstance(A,bytes):A=base64.b64encode(A).decode('utf-8')\n\tB=''.join([f' {A}=\"{B}\"'for(A,B)in meta.items()]);return f''\n_MIME_TO_RENDERERS={_A:lambda v,m:html.escape(v),_C:lambda v,m:v,_B:lambda v,m:_render_image(_B,v,m),_D:lambda v,m:_render_image(_D,v,m),_F:lambda v,m:v,_G:lambda v,m:v,_E:lambda v,m:f\"