From 3f05ba687bf0c50b7b334f656c3697a7cc0a2b7a Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Wed, 28 Oct 2026 07:12:52 +0000 Subject: [PATCH 1/3] Support dict_converter and iterating over python objects in js (#138) --- include/pyjs/pre_js/init.js | 19 +++++++++++++ include/pyjs/pre_js/make_proxy.js | 33 +++++++++++++++++++++++ module/pyjs/convert_py_to_js.py | 44 ++++++++++++++++++++++--------- src/js_timestamp.cpp | 2 +- tests/tests/test_pyjs.py | 18 +++++++++++++ 5 files changed, 102 insertions(+), 14 deletions(-) diff --git a/include/pyjs/pre_js/init.js b/include/pyjs/pre_js/init.js index 2ccb5cf..3f276e3 100644 --- a/include/pyjs/pre_js/init.js +++ b/include/pyjs/pre_js/init.js @@ -225,6 +225,25 @@ def _add_resolve_done_callback(future, resolve, reject): Module._py_to_js = Module.eval("pyjs.to_js") Module._py_objects.push(Module._py_to_js); + // helpers to iterate over python objects from JavaScript (see make_proxy.js) + Module.exec(` +def _pyjs_is_iterable(obj): + return hasattr(type(obj), "__iter__") + +def _pyjs_next(it, done): + try: + return next(it) + except StopIteration: + return done + `) + + Module._py_is_iterable = Module.eval("_pyjs_is_iterable") + Module._py_objects.push(Module._py_is_iterable); + Module._py_iter = Module.eval("iter") + Module._py_objects.push(Module._py_iter); + Module._py_next = Module.eval("_pyjs_next") + Module._py_objects.push(Module._py_next); + Module["to_js"] = function(obj){ return Module._py_to_js.py_call(obj) } diff --git a/include/pyjs/pre_js/make_proxy.js b/include/pyjs/pre_js/make_proxy.js index 4415e2d..3d75266 100644 --- a/include/pyjs/pre_js/make_proxy.js +++ b/include/pyjs/pre_js/make_proxy.js @@ -5,9 +5,42 @@ Module['make_proxy'] = function(py_object) { if (ret !== undefined) { return ret } + // python attribute names are strings, so symbols cannot be passed to _getattr + if (typeof property === 'symbol') { + if (property === Symbol.iterator && Module._py_is_iterable.py_call(target)) { + return function() { + return Module._py_object_iterator(target) + } + } + return undefined + } return target._getattr(property); } }; return new Proxy(py_object, handler); } + +// JavaScript iterator over an iterable python object +Module['_py_object_iterator'] = function(py_object) { + // returned by _py_next when the python iterator is exhausted + const done = {} + let py_iter = Module._py_iter.py_call(py_object) + return { + next() { + if (py_iter === null) { + return { done: true, value: undefined } + } + const value = Module._py_next.py_call(py_iter, done) + if (value === done) { + py_iter.delete() + py_iter = null + return { done: true, value: undefined } + } + return { done: false, value: value } + }, + [Symbol.iterator]() { + return this + } + } +} diff --git a/module/pyjs/convert_py_to_js.py b/module/pyjs/convert_py_to_js.py index 0233bf3..e5ef093 100644 --- a/module/pyjs/convert_py_to_js.py +++ b/module/pyjs/convert_py_to_js.py @@ -2,44 +2,58 @@ from .core import new from pyjs_core import JsValue, js_array, js, js_undefined,internal -def _py_list_like_to_js(value, cache, depth, max_depth): +def _py_list_like_to_js(value, cache, depth, max_depth, dict_converter): vid = id(value) if vid in cache: return cache[vid] j_value = js_array() cache[vid] = j_value for v in value: - js_v = to_js(v, cache=cache, depth=depth + 1, max_depth=max_depth) + js_v = to_js(v, cache=cache, depth=depth + 1, max_depth=max_depth, dict_converter=dict_converter) j_value.push(js_v) return j_value -def _py_dict_like_to_js(value, cache, depth, max_depth): +def _py_dict_like_to_js(value, cache, depth, max_depth, dict_converter): vid = id(value) if vid in cache: return cache[vid] + if dict_converter is not None: + # The converted value is only cached after all entries are converted, + # so recursive dicts are not supported. + entries = js_array() + for k, v in value.items(): + js_k = to_js(k, cache=cache, depth=depth + 1, max_depth=max_depth, dict_converter=dict_converter) + js_v = to_js(v, cache=cache, depth=depth + 1, max_depth=max_depth, dict_converter=dict_converter) + pair = js_array() + pair.push(js_k) + pair.push(js_v) + entries.push(pair) + j_value = dict_converter(entries) + cache[vid] = j_value + return j_value j_value = new(js.LiteralMap) cache[vid] = j_value for k, v in value.items(): - js_k = to_js(k, cache=cache, depth=depth + 1, max_depth=max_depth) - js_v = to_js(v, cache=cache, depth=depth + 1, max_depth=max_depth) + js_k = to_js(k, cache=cache, depth=depth + 1, max_depth=max_depth, dict_converter=dict_converter) + js_v = to_js(v, cache=cache, depth=depth + 1, max_depth=max_depth, dict_converter=dict_converter) j_value.set(js_k, js_v) return j_value -def _py_set_like_to_js(value, cache, depth, max_depth): +def _py_set_like_to_js(value, cache, depth, max_depth, dict_converter): vid = id(value) if vid in cache: return cache[vid] j_value = new(js.Set) cache[vid] = j_value for v in value: - js_v = to_js(v, cache=cache, depth=depth + 1, max_depth=max_depth) + js_v = to_js(v, cache=cache, depth=depth + 1, max_depth=max_depth, dict_converter=dict_converter) j_value.add(js_v) return j_value -def to_js(value, cache=None, depth=0, max_depth=None): +def to_js(value, cache=None, depth=0, max_depth=None, dict_converter=None): """ Convert a Python object to a JavaScript object. @@ -48,7 +62,11 @@ def to_js(value, cache=None, depth=0, max_depth=None): cache: A dictionary to use as a cache for already converted objects. depth: The current depth of of nested object conversion. max_depth: The maximum depth of nested object conversion. - + dict_converter: Optional function that receives a JavaScript array of + [key, value] pairs and returns the converted dict, e.g. + js.Object.fromEntries. If None, dicts are converted to a LiteralMap. + Recursive dicts are not supported when this is used. + Returns: a JavaScript stored in a pyjs_core.JsValue @@ -63,15 +81,15 @@ def to_js(value, cache=None, depth=0, max_depth=None): return value elif isinstance(value, (list, tuple)): return _py_list_like_to_js( - value=value, cache=cache, depth=depth, max_depth=max_depth + value=value, cache=cache, depth=depth, max_depth=max_depth, dict_converter=dict_converter ) elif isinstance(value, dict): return _py_dict_like_to_js( - value=value, cache=cache, depth=depth, max_depth=max_depth + value=value, cache=cache, depth=depth, max_depth=max_depth, dict_converter=dict_converter ) elif isinstance(value, set): return _py_set_like_to_js( - value=value, cache=cache, depth=depth, max_depth=max_depth + value=value, cache=cache, depth=depth, max_depth=max_depth, dict_converter=dict_converter ) elif value is None: return js_undefined() @@ -85,7 +103,7 @@ def to_js(value, cache=None, depth=0, max_depth=None): elif hasattr(value, "explicit_to_js"): return value.explicit_to_js() - + elif hasattr(value, "implicit_to_js"): return value.implicit_to_js() diff --git a/src/js_timestamp.cpp b/src/js_timestamp.cpp index a0e4475..50104a3 100644 --- a/src/js_timestamp.cpp +++ b/src/js_timestamp.cpp @@ -1 +1 @@ -#define PYJS_JS_UTC_TIMESTAMP "2026-09-07 08:23:38.609625" \ No newline at end of file +#define PYJS_JS_UTC_TIMESTAMP "2026-10-27 15:17:57.728418" diff --git a/tests/tests/test_pyjs.py b/tests/tests/test_pyjs.py index f229bbf..4ab8625 100644 --- a/tests/tests/test_pyjs.py +++ b/tests/tests/test_pyjs.py @@ -258,6 +258,24 @@ def test_to_js_dict(): assert jsmap.get(1) == "a" +def test_to_js_dict_converter(): + pydict = {"a": 1, "b": [2, {"c": "three"}]} + jsobj = pyjs.to_js(pydict, dict_converter=pyjs.js.Object.fromEntries) + assert pyjs.js.JSON.stringify(jsobj) == '{"a":1,"b":[2,{"c":"three"}]}' + + +def test_iterate_py_object_in_js(): + # python objects passed to js are proxies that implement Symbol.iterator + blob = pyjs.js.Blob.new(["hello", "world"]) + assert blob.size == 10 + + jsarray = getattr(pyjs.js.Array, "from")([1, "two", 3.5]) + assert pyjs.js.JSON.stringify(jsarray) == '[1,"two",3.5]' + + jsarray = getattr(pyjs.js.Array, "from")({"a": 1, "b": 2}) + assert pyjs.js.JSON.stringify(jsarray) == '["a","b"]' + + def test_bytes_to_js(): pyjs.to_js(b"\x00").byteLength == 1 From ccb54ea509c4d42dc2a5d1844362f20bccfc99db Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Wed, 28 Oct 2026 15:00:27 +0000 Subject: [PATCH 2/3] Try to fix build dependencies --- environment-wasm.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/environment-wasm.yml b/environment-wasm.yml index b898f82..c9425d8 100644 --- a/environment-wasm.yml +++ b/environment-wasm.yml @@ -1,6 +1,6 @@ name: pyjs-wasm-run channels: - - /Users/thorstenbeier/src/recipes/output + #- /Users/thorstenbeier/src/recipes/output - conda-forge - https://repo.prefix.dev/emscripten-forge-bot/emscripten-forge-6x @@ -12,4 +12,5 @@ dependencies: - numpy - zlib-static - libsqlite-static - - libbzip2-static \ No newline at end of file + - libbzip2-static + - libexpat-static \ No newline at end of file From 3d01f38d370899850a8a6b81aeb34a1d74e256da Mon Sep 17 00:00:00 2001 From: Thorsten Beier Date: Fri, 30 Oct 2026 09:33:14 +0100 Subject: [PATCH 3/3] Fix libbzip2-static entry in environment-wasm.yml --- environment-wasm.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/environment-wasm.yml b/environment-wasm.yml index c9425d8..e05670d 100644 --- a/environment-wasm.yml +++ b/environment-wasm.yml @@ -13,4 +13,3 @@ dependencies: - zlib-static - libsqlite-static - libbzip2-static - - libexpat-static \ No newline at end of file